mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-14 04:46:07 -08:00
Compare commits
23
Commits
04b878dd93
...
v0.20.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffa2bce94
|
||
|
|
88b73656a7
|
||
|
|
e77aa5c3b3 | ||
|
|
121b688afb | ||
|
|
727be5683a | ||
|
|
138bfe9759 | ||
|
|
0aba17d95b
|
||
|
|
a5d0b63792 | ||
|
|
15cb9fe918
|
||
|
|
db45a70ec2
|
||
|
|
9e79fa93c2 | ||
|
|
9fadbc8cdd
|
||
|
|
ffbbe698fc | ||
|
|
7b72dd2cc4
|
||
|
|
416cd7e9ac
|
||
|
|
eb749f8c19
|
||
|
|
65ac5a7728 | ||
|
|
df675cfbfe
|
||
|
|
e49b5f6589
|
||
|
|
0bbd8617f4 | ||
|
|
4e43c5c6e1 | ||
|
|
953abdf42a
|
||
|
|
fdb0a31fff
|
No files matched your search
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
### Changed
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
### Fixed
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -21,13 +21,13 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron": "43.7.7",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.29.0",
|
||||
"undici": "7.29.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -37,7 +37,7 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.16.1",
|
||||
"brace-expansion": "5.0.9",
|
||||
"brace-expansion": "5.0.11",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.7",
|
||||
"form-data": "4.0.6",
|
||||
@@ -48,7 +48,7 @@
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0",
|
||||
"undici": "7.29.1",
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -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=="],
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.11", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-awigjhi6cLTh90bdw6+QJ9CtmJmyYhEIi70iCbc8Rozn04Fw9FeQIBjv/E22FFGuCGx1bLJyUfB64x/szUSXUg=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
|
||||
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
@@ -717,7 +717,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: app
|
||||
|
||||
- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: docs
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
|
||||
- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Timing review previews and mined card audio now use the audio track you are playing on Jellyfin direct-play streams, instead of the track Jellyfin marks as default.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
|
||||
- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: added
|
||||
area: youtube
|
||||
|
||||
- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
|
||||
- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
|
||||
- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
|
||||
- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: added
|
||||
area: youtube
|
||||
|
||||
- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
|
||||
- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
|
||||
- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
|
||||
@@ -124,6 +124,8 @@ With `media.reviewTiming` on, SubMiner pauses before making media for word, sent
|
||||
|
||||
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
|
||||
|
||||
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
|
||||
|
||||
| Action | How |
|
||||
| ------------------------ | --------------------------------------------------------------------- |
|
||||
| Trim | Drag either edge, or click the waveform to move the nearer edge there |
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
**Added**
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
**Changed**
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
**Fixed**
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
**Docs**
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ electron . --start --dev --log-level debug # verbose launch of an existing bui
|
||||
electron . --background # tray/background mode
|
||||
```
|
||||
|
||||
Development and debug launches use a separate `SubMiner-dev` profile so runtime experiments cannot modify the installed app's configuration or Yomitan dictionaries. To intentionally use the production profile for a development launch, set `SUBMINER_USE_PRODUCTION_PROFILE=1`. Use that override only after backing up the profile.
|
||||
|
||||
Launch source builds through `bun run dev` or `bun run electron`. SubMiner refuses to load its profile when the running Electron major differs from the version pinned by the repository.
|
||||
|
||||
To test through the mpv plugin without exporting `SUBMINER_BINARY_PATH` each time, point `mpv.subminerBinaryPath` in your config at the dev script. The launcher passes it to the plugin at runtime:
|
||||
|
||||
```json
|
||||
|
||||
@@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
|
||||
|
||||
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
||||
|
||||
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
|
||||
|
||||

|
||||
|
||||
### Library
|
||||
@@ -122,4 +124,6 @@ Leave out `--lookback-days` to scan all history. Word and kanji counts are corre
|
||||
|
||||
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
|
||||
|
||||
Deleting retained sessions preserves earlier word and kanji discovery dates from pruned history. New occurrences record observation time so deletion can update their dates exactly. Older history used write time, so an earlier discovery date is preserved when its origin cannot be determined.
|
||||
|
||||
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
|
||||
+14
-14
@@ -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)
|
||||
|
||||
@@ -88,6 +88,8 @@ Built into the overlay, not configurable:
|
||||
| `Alt+C` | Open controller setup and remapping | `shortcuts.openControllerSelect` |
|
||||
| `Alt+Shift+C` | Open controller debug view | `shortcuts.openControllerDebug` |
|
||||
|
||||
While a video is playing, session help can also run commands: highlight a row with the arrow keys or the mouse, then press `Enter` or double-click it. Fixed overlay controls, global shortcuts, `y` chords, the multi-line copy and mine actions, Open session help, and the color legend are reference only. Help opened from the tray with no video loaded is read-only.
|
||||
|
||||
The sidebar key has a separate mpv-side binding, `shortcuts.toggleSubtitleSidebar`. The sidebar only opens when SubMiner has parsed the active subtitle file. In the sidebar, `Enter` seeks to the focused line.
|
||||
|
||||
The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Behavior, Subtitle Selection**. Press the second key within one second. If `g` already has an action in SubMiner or mpv, the sequence is disabled and a warning is shown. See [subtitle selection](/configuration#subtitle-selection).
|
||||
@@ -130,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.
|
||||
@@ -67,6 +67,16 @@ If you have not set up dictionaries yet, start with [Yomitan setup](/usage#yomit
|
||||
|
||||
Word boundaries come from Yomitan's parser. Some splits will be wrong, since Japanese has no spaces.
|
||||
|
||||
## "Electron downgrade blocked" or "Unsupported Electron runtime"
|
||||
|
||||
SubMiner refuses to load Yomitan storage when the current Electron major does not match the app build, or when the profile was previously opened by a newer Electron major or minor version. Patch downgrades within the same major and minor version are allowed, such as `43.7.3` to `43.7.2`. Downgrades from `43.7.x` to `43.6.x`, or from Electron 43 to 42, are blocked. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record to force an older major or minor version to open the profile.
|
||||
|
||||
Older SubMiner builds without this guard cannot enforce the safety record. When testing Electron 43 before returning to an older Electron 42 build, use the separate `SubMiner-dev` profile described in [development](/development#run-locally) so the older build never opens the upgraded profile.
|
||||
|
||||
## "Yomitan reported zero dictionaries after previously reporting ..."
|
||||
|
||||
SubMiner detected that a previously non-empty Yomitan profile suddenly appears empty. Automatic character-dictionary changes are blocked so they cannot overwrite the suspicious state. Close SubMiner, preserve the profile directory, and restore a known-good backup before importing or deleting dictionaries.
|
||||
|
||||
## "Yomitan extension not found in any search path"
|
||||
|
||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
||||
@@ -225,6 +235,8 @@ bind = ALT SHIFT, Y, pass, class:^(SubMiner)$
|
||||
|
||||
If the overlay stays behind fullscreen mpv, check that the mpv socket is connected and that `hyprctl -j clients` works from the environment that launched SubMiner.
|
||||
|
||||
SubMiner restacks the existing overlay during fullscreen transitions on Hyprland. It keeps the overlay mapped so repeated hide/show calls do not steal focus and cancel mpv's fullscreen entry. Sway retains the hide/show refresh because SubMiner has no Sway-specific restacking implementation.
|
||||
|
||||
See the Hyprland wiki on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
|
||||
|
||||
### KDE Plasma and other Wayland compositors
|
||||
|
||||
@@ -145,6 +145,10 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear
|
||||
|
||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
|
||||
|
||||
## Development profiles
|
||||
|
||||
`--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set. See [Development](/development#run-locally).
|
||||
|
||||
## Changing settings while you watch
|
||||
|
||||
SubMiner watches your config file and applies most changes without a restart, including subtitle style, keybindings, and most Anki settings. If a change needs a restart, SubMiner tells you. If the file has an error, it keeps the last working config and shows a notification. See [Configuration](/configuration).
|
||||
|
||||
@@ -24,6 +24,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
||||
@@ -47,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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -86,7 +86,7 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.16.1",
|
||||
"brace-expansion": "5.0.9",
|
||||
"brace-expansion": "5.0.11",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.7",
|
||||
"form-data": "4.0.6",
|
||||
@@ -97,7 +97,7 @@
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0"
|
||||
"undici": "7.29.1"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -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",
|
||||
@@ -128,13 +128,13 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron": "43.7.7",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.29.0"
|
||||
"undici": "7.29.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.sudacode.SubMiner",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+66
-103
@@ -1,125 +1,88 @@
|
||||
> 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 -->
|
||||
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
|
||||
|
||||
## Changes since v0.20.0-beta.1
|
||||
## Changes since v0.20.1-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.
|
||||
- Upgraded the desktop runtime to Electron 43.7.7.
|
||||
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
|
||||
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
|
||||
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
|
||||
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
|
||||
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
|
||||
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
|
||||
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
|
||||
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
|
||||
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
|
||||
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
|
||||
|
||||
## 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 it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start.
|
||||
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
|
||||
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
|
||||
- **Mouse and Scroll Wheel Bindings**:
|
||||
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
|
||||
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
|
||||
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
|
||||
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **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.
|
||||
|
||||
### 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 track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
|
||||
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
|
||||
- If one track fails to download, the other tracks are still selected.
|
||||
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
|
||||
- **Media Timing Review**:
|
||||
- Trim handles are now thin lines centered on the clip edges.
|
||||
- Original subtitle boundaries are now plain orange bars with no labels.
|
||||
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
|
||||
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
|
||||
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
|
||||
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
|
||||
- **Stats Accuracy**:
|
||||
- Sentence mining no longer counts each card twice in session and lifetime stats.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
|
||||
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
|
||||
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
|
||||
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
|
||||
|
||||
- **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. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
|
||||
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
|
||||
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
|
||||
|
||||
## 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(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube in a window that keeps your login across restarts. Open it with `subminer youtube` (or `subminer yt`), or choose **Browse YouTube** from the tray.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start playing.
|
||||
- **Whisper Subtitles for YouTube**: Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions. The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay.
|
||||
- **Run Commands From Session Help**: Press `Enter` or double-click a row in session help to run that command during playback.
|
||||
|
||||
### Fixed
|
||||
- **YouTube**:
|
||||
- Japanese subtitles no longer fail with "HTTP 429" on videos that also have a machine-translated Japanese track.
|
||||
- On Windows, the subtitle generation modal now works during YouTube playback.
|
||||
- On Windows, playing a video queued from the YouTube browser window no longer clears the rest of mpv's queue.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, instead of waiting for every other track.
|
||||
- Japanese and English image-based subtitles (PGS, DVD, DVB) are now selected automatically.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Hyprland Overlay**: The overlay no longer interrupts mpv when it goes fullscreen. With `mpv.backend: x11`, the overlay stays clickable after mpv goes fullscreen.
|
||||
- **mpv Key Bindings**: Keys from mpv's defaults and your `input.conf` (such as `9` and `0` for volume) now work while the overlay has focus, even when the overlay loads before SubMiner connects to mpv.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and the original subtitle boundaries are plain orange bars. On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly.
|
||||
- **Stats**:
|
||||
- Each mined card is counted once instead of twice.
|
||||
- Overview totals, charts, the calendar, and the vocabulary summary update right after you delete sessions.
|
||||
- Word and kanji dates stay correct after deletes and pruning, and the new-words charts now agree with the vocabulary summary. Existing data is repaired in the background on the next launch.
|
||||
- **Yomitan Profile Safety**: Electron is updated to 43.7.7, and SubMiner now refuses to load your Yomitan data with an older or unsupported runtime. Automatic character-dictionary updates stop if a profile that had dictionaries suddenly reports none.
|
||||
- **Security Updates**: Updated Electron and undici to resolve security advisories.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens a window when launched inside a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when you stop the terminal command that started it with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, and the configuration reference now has a table of keys and defaults for each section. Outdated pages were corrected, and the Arch Linux MeCab install now uses the AUR package `mecab-git`.
|
||||
- **Stats**: Added docs on how the Overview refreshes after you delete sessions and how discovery dates are kept after pruning.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- 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
|
||||
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||
|
||||
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||
|
||||
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||
@@ -44,20 +44,12 @@ function fragmentTypesInPrompt(input: string): string[] {
|
||||
.map((line) => line.slice('type: '.length).trim());
|
||||
}
|
||||
|
||||
function assertPromptRequestsNestedBullets(input: string): void {
|
||||
assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
|
||||
assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
|
||||
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
|
||||
assert.match(input, /Avoid paragraph-style release-note bullets/);
|
||||
}
|
||||
|
||||
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
|
||||
assertPromptRequestsNestedBullets(input);
|
||||
assert.match(
|
||||
input,
|
||||
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
|
||||
);
|
||||
assert.match(input, /Do not require the exact nested labels/);
|
||||
function assertPromptRequestsConciseItems(input: string): void {
|
||||
assert.match(input, /Default to inline/);
|
||||
assert.match(input, /Use nested bullets only when an item has several distinct changes/);
|
||||
assert.match(input, /Never write paragraph-length bullets/);
|
||||
assert.match(input, /Merge aggressively: one item per feature or area/);
|
||||
assert.match(input, /Release notes summarize; the docs hold the details/);
|
||||
}
|
||||
|
||||
function defaultPolishedBody(input: string): string {
|
||||
@@ -454,14 +446,13 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
assertPromptRequestsNestedBullets(prompt);
|
||||
assertPromptRequestsConciseItems(prompt);
|
||||
}
|
||||
|
||||
const releaseNotesPrompt = stub.calls.find(
|
||||
(call) => modeFromPrompt(call.input) === 'release-notes',
|
||||
);
|
||||
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
|
||||
assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -817,7 +808,11 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
assertReleaseNotesPromptRequestsNestedBullets(prompt);
|
||||
assert.match(
|
||||
prompt,
|
||||
/shorten long baseline bullets and collapse nested lists that do not need to be nested/,
|
||||
);
|
||||
assertPromptRequestsConciseItems(prompt);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
+24
-16
@@ -476,21 +476,29 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
|
||||
|
||||
</details>
|
||||
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
|
||||
4. Each top-level change item should:
|
||||
- Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
|
||||
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
||||
4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
|
||||
- Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
|
||||
- Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
|
||||
- Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
|
||||
5. Keep items brief. Release notes summarize; the docs hold the details.
|
||||
- Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
|
||||
- Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
|
||||
- Name a config key only when users must set it to use the change or to keep their current behavior.
|
||||
- Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||
- Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
|
||||
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
|
||||
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
|
||||
- **Playlist Browser**:
|
||||
- Saved shows now open without rescanning the library.
|
||||
- The picker remembers the last folder you browsed between launches.
|
||||
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
|
||||
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
|
||||
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
|
||||
Bullets inside the Internal section may stay single-level.
|
||||
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
|
||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
|
||||
6. Match the length and density of this example:
|
||||
### Added
|
||||
- **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
|
||||
### Changed
|
||||
- **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
|
||||
### Fixed
|
||||
- **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
|
||||
- **Jellyfin 12**:
|
||||
- Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
|
||||
- Casting no longer drops after about a minute.
|
||||
- Mined cards get the episode title again.
|
||||
7. Do not invent features. Every bullet must be grounded in the input fragments.
|
||||
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
||||
|
||||
@@ -791,7 +799,7 @@ function polishFragmentsWithClaude(
|
||||
? [
|
||||
'## Existing Prerelease Notes',
|
||||
'',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
@@ -814,7 +822,7 @@ Rules:
|
||||
3. ADDED fragments describe changes that are new in this build; summarize them.
|
||||
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
|
||||
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
|
||||
6. Keep bullets short and concrete. Use nested bullets sparingly.
|
||||
6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
|
||||
7. Do not invent changes. Every bullet must be grounded in the diffs.
|
||||
8. If no bullet survives rules 2-5, output exactly this single line:
|
||||
- No user-facing changes since PREVIOUS_TAG.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
|
||||
async function backfillMissingCoverBlob(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -103,9 +103,15 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
||||
test(
|
||||
'fetchIfMissing backfills a missing blob from an existing cover URL',
|
||||
{ timeout: 15_000 },
|
||||
backfillMissingCoverBlob,
|
||||
);
|
||||
|
||||
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -179,7 +185,13 @@ test('fetchIfMissing reuses cached cover art from another video in the same anim
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'fetchIfMissing reuses cached cover art from another video in the same anime',
|
||||
{ timeout: 15_000 },
|
||||
reuseCachedAnimeCoverArt,
|
||||
);
|
||||
|
||||
function createJsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
|
||||
@@ -5,8 +5,9 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { ensureSchema } from '../storage.js';
|
||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from '../storage.js';
|
||||
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
|
||||
import { pruneRawRetention } from '../maintenance.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
@@ -30,7 +31,13 @@ function cleanupDbPath(dbPath: string): void {
|
||||
*/
|
||||
function seed(
|
||||
db: DatabaseSync,
|
||||
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
|
||||
lines: Array<{
|
||||
session: 1 | 2;
|
||||
wordId: number;
|
||||
dayOffset: number;
|
||||
msOffset?: number;
|
||||
count?: number;
|
||||
}>,
|
||||
options: { legacyRows?: boolean } = {},
|
||||
): void {
|
||||
db.exec(`
|
||||
@@ -65,7 +72,7 @@ function seed(
|
||||
let lineId = 0;
|
||||
for (const line of lines) {
|
||||
lineId += 1;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
|
||||
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
||||
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
||||
if (options.legacyRows) {
|
||||
@@ -83,13 +90,13 @@ function seed(
|
||||
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
||||
),
|
||||
first_seen = (
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000.0
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
),
|
||||
last_seen = (
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
@@ -200,6 +207,167 @@ test('deleting the latest session moves last_seen back to the surviving line', (
|
||||
}
|
||||
});
|
||||
|
||||
for (const legacyRows of [false, true]) {
|
||||
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
|
||||
// Stored seen values are fractional seconds; whole-second rounding used to miss
|
||||
// the removed extreme (or truncate the refreshed one).
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
|
||||
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
|
||||
],
|
||||
{ legacyRows },
|
||||
);
|
||||
|
||||
try {
|
||||
deleteSession(db, 2);
|
||||
|
||||
const word = readWord(db, 22);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
|
||||
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('deleting legacy history preserves first_seen when it predates the flush timestamp', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
|
||||
{ session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
|
||||
]);
|
||||
|
||||
try {
|
||||
// A queue delay and retention-pruned history cannot be distinguished from
|
||||
// the old flush-time timestamp. Preserve the earlier date in either case.
|
||||
db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
assert.equal(readWord(db, 23)?.firstSeen, BASE_MS / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
for (const legacyRows of [false, true]) {
|
||||
for (const deletedSession of [2, 3]) {
|
||||
test(`deleting retained session ${deletedSession} preserves pruned first_seen (legacyRows=${legacyRows})`, () => {
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 24, dayOffset: 0 },
|
||||
{ session: 2, wordId: 24, dayOffset: 1 },
|
||||
],
|
||||
{ legacyRows },
|
||||
);
|
||||
|
||||
try {
|
||||
const latestMs = BASE_MS + 2 * DAY_MS;
|
||||
db.exec(`
|
||||
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (3, 's3', 2, '${latestMs}', '${latestMs + 1000}', 2, ${latestMs}, ${latestMs});
|
||||
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (3, 3, 2, 3, '猫', ${latestMs}, ${latestMs});
|
||||
INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||
VALUES (3, 24, 1, ${legacyRows ? 'NULL' : latestMs});
|
||||
UPDATE imm_words SET frequency = 3, last_seen = ${latestMs / 1000} WHERE id = 24;
|
||||
INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency)
|
||||
SELECT 24, '猫', first_seen, last_seen, frequency FROM imm_words WHERE id = 24;
|
||||
INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||
SELECT line_id, 24, occurrence_count, seen_ms FROM imm_word_line_occurrences WHERE word_id = 24;
|
||||
`);
|
||||
pruneRawRetention(db, latestMs, {
|
||||
eventsRetentionMs: Infinity,
|
||||
telemetryRetentionMs: Infinity,
|
||||
sessionsRetentionMs: 1.5 * DAY_MS,
|
||||
});
|
||||
|
||||
deleteSession(db, deletedSession);
|
||||
|
||||
for (const table of ['imm_words', 'imm_kanji']) {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table} WHERE id = 24`,
|
||||
)
|
||||
.get() as { frequency: number; firstSeen: number; lastSeen: number };
|
||||
assert.equal(row.frequency, 2);
|
||||
assert.equal(row.firstSeen, BASE_MS / 1000, `${table} keeps the pruned discovery date`);
|
||||
assert.equal(row.lastSeen, (BASE_MS + (deletedSession === 2 ? 2 : 1) * DAY_MS) / 1000);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('deleting a queued subtitle uses observation time despite a delayed flush', () => {
|
||||
const { db, dbPath } = createDb([]);
|
||||
|
||||
try {
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
for (const sessionId of [1, 2]) {
|
||||
const seenMs = BASE_MS + (sessionId - 1) * DAY_MS + 400;
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'subtitleLine',
|
||||
sessionId,
|
||||
videoId: sessionId,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: null,
|
||||
segmentEndMs: null,
|
||||
text: '猫',
|
||||
wordOccurrences: [
|
||||
{
|
||||
headword: '猫',
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
partOfSpeech: 'noun',
|
||||
pos1: '名詞',
|
||||
pos2: '一般',
|
||||
pos3: '',
|
||||
occurrenceCount: 1,
|
||||
frequencyRank: null,
|
||||
},
|
||||
],
|
||||
kanjiOccurrences: [{ kanji: '猫', occurrenceCount: 1 }],
|
||||
firstSeen: seenMs / 1000,
|
||||
lastSeen: seenMs / 1000,
|
||||
},
|
||||
stmts,
|
||||
);
|
||||
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
|
||||
const row = db
|
||||
.prepare(`SELECT seen_ms AS seenMs FROM ${table} WHERE line_id = ?`)
|
||||
.get(sessionId) as { seenMs: number };
|
||||
assert.equal(row.seenMs, seenMs);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
for (const table of ['imm_words', 'imm_kanji']) {
|
||||
const row = db
|
||||
.prepare(`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table}`)
|
||||
.get() as {
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
assert.equal(row.frequency, 1);
|
||||
assert.equal(row.firstSeen, (BASE_MS + DAY_MS + 400) / 1000);
|
||||
assert.equal(row.lastSeen, (BASE_MS + DAY_MS + 400) / 1000);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
||||
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
||||
const { db, dbPath } = createDb([
|
||||
|
||||
@@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
||||
|
||||
assert.ok(sharedWordRow);
|
||||
assert.equal(sharedWordRow.frequency, 1);
|
||||
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedWordRow.last_seen, keptTs / 1000);
|
||||
assert.equal(deletedOnlyWordRow ?? null, null);
|
||||
assert.ok(sharedKanjiRow);
|
||||
assert.equal(sharedKanjiRow.frequency, 1);
|
||||
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
|
||||
assert.equal(deletedOnlyKanjiRow ?? null, null);
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from './query-lexical';
|
||||
import { Database } from './sqlite';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { ensureSchema } from './storage';
|
||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
||||
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
|
||||
@@ -125,6 +126,121 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
|
||||
}
|
||||
});
|
||||
|
||||
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const firstSeen = BASE_MS / 1000;
|
||||
// Legacy row whose stored merged-token POS fails the persistence filter.
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
|
||||
).run(firstSeen, firstSeen);
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
// The incoming token passes the filter, but the upsert keeps the stored POS.
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'subtitleLine',
|
||||
sessionId,
|
||||
videoId,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: null,
|
||||
segmentEndMs: null,
|
||||
text: 'にし',
|
||||
wordOccurrences: [
|
||||
{
|
||||
headword: '二死',
|
||||
word: 'にし',
|
||||
reading: 'にし',
|
||||
partOfSpeech: 'verb',
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
pos3: '*',
|
||||
frequencyRank: null,
|
||||
occurrenceCount: 1,
|
||||
},
|
||||
],
|
||||
kanjiOccurrences: [],
|
||||
firstSeen: firstSeen + 60,
|
||||
lastSeen: firstSeen + 60,
|
||||
},
|
||||
createTrackerPreparedStatements(db),
|
||||
);
|
||||
|
||||
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
|
||||
(sum, day) => sum + day.wordCount,
|
||||
0,
|
||||
);
|
||||
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||
assert.equal(plotted, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const lineId = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, 1, '猫', ?, ?)`,
|
||||
)
|
||||
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
|
||||
);
|
||||
const seen = (BASE_MS + 251) / 1000;
|
||||
// first_seen this early may be real history pruned by retention; never touched.
|
||||
const earlyFirst = seen - 86_400.5;
|
||||
const staleLast = seen + 86_400.631;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
|
||||
).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
|
||||
).run(earlyFirst, staleLast);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251);
|
||||
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
const bounds = (table: string): Array<[number, number]> =>
|
||||
(
|
||||
db
|
||||
.prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
|
||||
.all() as Array<{
|
||||
first: number;
|
||||
last: number;
|
||||
}>
|
||||
).map((row) => [row.first, row.last]);
|
||||
assert.deepEqual(
|
||||
bounds('imm_words'),
|
||||
[
|
||||
[earlyFirst, seen],
|
||||
[seen - 0.1, seen - 0.1],
|
||||
],
|
||||
'healthy rows are untouched',
|
||||
);
|
||||
assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -268,7 +384,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||
VALUES ('lexical_daily_rollups_version', 2);
|
||||
VALUES ('lexical_daily_rollups_version', 3);
|
||||
`);
|
||||
|
||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||
|
||||
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
|
||||
) - 2440587.5 AS INTEGER)
|
||||
`;
|
||||
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
|
||||
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
||||
// last_seen values left behind by session deletes.
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
||||
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
||||
|
||||
@@ -26,6 +28,32 @@ export function localEpochDaySql(value: string): string {
|
||||
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
|
||||
}
|
||||
|
||||
// Session deletes once rounded removed timestamps to whole seconds, missing
|
||||
// fractional last_seen extremes that pointed at deleted history. New seen_ms
|
||||
// values match observation time; legacy values were stamped later at flush.
|
||||
// Retention prunes the oldest sessions first, so healthy rows never exceed
|
||||
// their newest surviving occurrence. Rows with undated occurrences are skipped.
|
||||
//
|
||||
// first_seen is deliberately not repaired: a stale bound is indistinguishable
|
||||
// from real history whose occurrences retention pruned, and frequency does not
|
||||
// reliably tell them apart.
|
||||
function repairStaleLastSeen(db: DatabaseSync): void {
|
||||
for (const [table, occurrenceTable, col] of [
|
||||
['imm_words', 'imm_word_line_occurrences', 'word_id'],
|
||||
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
|
||||
] as const) {
|
||||
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
|
||||
db.exec(`
|
||||
UPDATE ${table}
|
||||
SET last_seen = ${newestSeen}
|
||||
WHERE last_seen > ${newestSeen}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function createWordRollupTriggers(db: DatabaseSync): void {
|
||||
const dayForNew = localEpochDaySql('NEW.first_seen');
|
||||
const dayForOld = localEpochDaySql('OLD.first_seen');
|
||||
@@ -186,6 +214,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
|
||||
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
|
||||
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
|
||||
}
|
||||
repairStaleLastSeen(db);
|
||||
db.exec('DELETE FROM imm_lexical_daily_rollups');
|
||||
db.exec(`
|
||||
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
|
||||
|
||||
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
|
||||
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
||||
}
|
||||
|
||||
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
|
||||
// them, or comparisons against stored extremes miss and refreshes drift.
|
||||
function toStoredSeenSeconds(ms: number | null): number | null {
|
||||
if (ms === null || !Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
return ms / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +322,13 @@ export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan)
|
||||
applyRemovalsForEntity(db, 'kanji', plan.kanji);
|
||||
}
|
||||
|
||||
// A timestamp repair must keep subtraction-exact lifetime counts and any
|
||||
// first_seen date that the deleted occurrences cannot account for.
|
||||
type LexicalAggregateOverride = {
|
||||
frequency: number | null;
|
||||
firstSeen: number | null;
|
||||
};
|
||||
|
||||
function applyRemovalsForEntity(
|
||||
db: DatabaseSync,
|
||||
entity: LexicalEntity,
|
||||
@@ -358,7 +367,7 @@ function applyRemovalsForEntity(
|
||||
`UPDATE ${entityTable} SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = ?`,
|
||||
);
|
||||
|
||||
const needsExactRefresh: number[] = [];
|
||||
const needsExactRefresh = new Map<number, LexicalAggregateOverride>();
|
||||
|
||||
for (const removal of removals) {
|
||||
const current = selectStmt.get(removal.id) as {
|
||||
@@ -369,17 +378,9 @@ function applyRemovalsForEntity(
|
||||
if (!current) continue;
|
||||
|
||||
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
|
||||
if (nextFrequency <= 0) {
|
||||
// The rows in scope are already gone by now, so anything still pointing at
|
||||
// this entity means the stored frequency was stale rather than exhausted.
|
||||
if (hasOccurrencesStmt.get(removal.id)) {
|
||||
needsExactRefresh.push(removal.id);
|
||||
} else {
|
||||
deleteStmt.run(removal.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Legacy occurrences use flush time, which can follow the actual observation.
|
||||
// An earlier stored date may also belong to retention-pruned history. Only
|
||||
// move it when the removed timestamps prove that its observation was deleted.
|
||||
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
|
||||
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
|
||||
const firstSeenMayHaveMoved =
|
||||
@@ -388,11 +389,26 @@ function applyRemovalsForEntity(
|
||||
const lastSeenMayHaveMoved =
|
||||
current.lastSeen === null ||
|
||||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
|
||||
const exactRefreshOverride = {
|
||||
frequency: nextFrequency > 0 ? nextFrequency : null,
|
||||
firstSeen: firstSeenMayHaveMoved ? null : current.firstSeen,
|
||||
};
|
||||
if (nextFrequency <= 0) {
|
||||
// The rows in scope are already gone by now, so anything still pointing at
|
||||
// this entity means the stored frequency was stale rather than exhausted.
|
||||
if (hasOccurrencesStmt.get(removal.id)) {
|
||||
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||
} else {
|
||||
deleteStmt.run(removal.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
|
||||
// Undated pre-migration occurrences are invisible to the seeks below;
|
||||
// fall back to the full re-aggregate that resolves their dates.
|
||||
// resolve their dates without discarding retention-pruned aggregates.
|
||||
if (hasUndatedOccurrenceStmt.get(removal.id)) {
|
||||
needsExactRefresh.push(removal.id);
|
||||
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||
continue;
|
||||
}
|
||||
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
|
||||
@@ -400,13 +416,13 @@ function applyRemovalsForEntity(
|
||||
if (minSeenMs === null || maxSeenMs === null) {
|
||||
// Frequency says occurrences remain but none exist: stale row, let the
|
||||
// exact refresh reconcile (it deletes rows with nothing left).
|
||||
needsExactRefresh.push(removal.id);
|
||||
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||
continue;
|
||||
}
|
||||
updateAggregatesStmt.run(
|
||||
nextFrequency,
|
||||
Math.floor(Number(minSeenMs) / 1000),
|
||||
Math.floor(Number(maxSeenMs) / 1000),
|
||||
firstSeenMayHaveMoved ? toStoredSeenSeconds(Number(minSeenMs)) : current.firstSeen,
|
||||
toStoredSeenSeconds(Number(maxSeenMs)),
|
||||
removal.id,
|
||||
);
|
||||
continue;
|
||||
@@ -416,13 +432,17 @@ function applyRemovalsForEntity(
|
||||
}
|
||||
|
||||
if (entity === 'word') {
|
||||
refreshWordAggregates(db, needsExactRefresh);
|
||||
refreshWordAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
|
||||
} else {
|
||||
refreshKanjiAggregates(db, needsExactRefresh);
|
||||
refreshKanjiAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
function refreshWordAggregates(
|
||||
db: DatabaseSync,
|
||||
wordIds: number[],
|
||||
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
|
||||
): void {
|
||||
if (wordIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -470,15 +490,19 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
continue;
|
||||
}
|
||||
updateStmt.run(
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
overrides?.get(row.wordId)?.frequency ?? row.frequency,
|
||||
overrides?.get(row.wordId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.wordId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
||||
function refreshKanjiAggregates(
|
||||
db: DatabaseSync,
|
||||
kanjiIds: number[],
|
||||
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
|
||||
): void {
|
||||
if (kanjiIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -526,9 +550,9 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
||||
continue;
|
||||
}
|
||||
updateStmt.run(
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
overrides?.get(row.kanjiId)?.frequency ?? row.frequency,
|
||||
overrides?.get(row.kanjiId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.kanjiId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||
@@ -17,7 +18,8 @@ export interface TrackerPreparedStatements {
|
||||
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
@@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
|
||||
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
|
||||
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
|
||||
vocabulary_visible = 1,
|
||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
|
||||
frequency_rank = CASE
|
||||
@@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
wordIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_words
|
||||
wordSelectStmt: db.prepare(`
|
||||
SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
|
||||
pos1, pos2, pos3, frequency_rank AS frequencyRank,
|
||||
vocabulary_visible AS vocabularyVisible
|
||||
FROM imm_words
|
||||
WHERE headword = ? AND word = ? AND reading = ?
|
||||
`),
|
||||
wordVisibilityUpdateStmt: db.prepare(`
|
||||
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
|
||||
`),
|
||||
kanjiIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_kanji
|
||||
WHERE kanji = ?
|
||||
@@ -1842,14 +1849,18 @@ function incrementWordAggregate(
|
||||
occurrence.frequencyRank ?? null,
|
||||
);
|
||||
}
|
||||
const row = stmts.wordIdSelectStmt.get(
|
||||
occurrence.headword,
|
||||
occurrence.word,
|
||||
occurrence.reading,
|
||||
) as { id: number } | null;
|
||||
const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
|
||||
| (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
|
||||
| null;
|
||||
if (!row?.id) {
|
||||
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
|
||||
}
|
||||
// The upsert keeps the first-seen POS metadata, so derive visibility from the
|
||||
// stored row (as the vocabulary summary does) rather than the incoming token.
|
||||
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
|
||||
if (row.vocabularyVisible !== visible) {
|
||||
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
|
||||
@@ -1952,18 +1963,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
currentMs,
|
||||
);
|
||||
const lineId = Number(lineResult.lastInsertRowid);
|
||||
// Match aggregate bounds to the observation, even if this write was queued
|
||||
// for a while. Flush time cannot identify which occurrence set first_seen.
|
||||
const seenMs = toDbTimestamp(Math.round(write.firstSeen * 1000));
|
||||
for (const occurrence of write.wordOccurrences) {
|
||||
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
|
||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, seenMs);
|
||||
}
|
||||
for (const occurrence of write.kanjiOccurrences) {
|
||||
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||
stmts.kanjiLineOccurrenceUpsertStmt.run(
|
||||
lineId,
|
||||
kanjiId,
|
||||
occurrence.occurrenceCount,
|
||||
currentMs,
|
||||
);
|
||||
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount, seenMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { describe, test } from 'node:test';
|
||||
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
|
||||
@@ -38,6 +39,22 @@ describe('buildMediaTimingPreviewArgs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps macOS previews on coreaudio with packed float output', () => {
|
||||
const options = { mediaPath: '/tmp/window.mkv' };
|
||||
|
||||
assert.ok(
|
||||
buildMediaTimingPreviewArgs('/tmp/review.sock', options, 'darwin').includes(
|
||||
'--audio-format=float',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
buildMediaTimingPreviewArgs('/tmp/review.sock', options, 'linux').some((arg) =>
|
||||
arg.startsWith('--audio-format='),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('separates an option-like media path without adding optional audio arguments', () => {
|
||||
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||
mediaPath: '--fullscreen',
|
||||
@@ -217,6 +234,15 @@ function createFakeSocket() {
|
||||
socket.destroyed = false;
|
||||
socket.write = (data) => {
|
||||
writes.push(data);
|
||||
const message = JSON.parse(data);
|
||||
if (message.request_id !== undefined) {
|
||||
queueMicrotask(() =>
|
||||
socket.emit(
|
||||
'data',
|
||||
`${JSON.stringify({ request_id: message.request_id, error: 'success' })}\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
socket.end = () => undefined;
|
||||
@@ -226,6 +252,85 @@ function createFakeSocket() {
|
||||
return { socket, writes };
|
||||
}
|
||||
|
||||
test('preview waits for loaded media before seeking and reports the actual playback position', async () => {
|
||||
const { socket, writes } = createFakeSocket();
|
||||
const child = new ChildProcess();
|
||||
child.kill = () => true;
|
||||
const positions: number[] = [];
|
||||
const session = new MediaTimingPreviewSession({
|
||||
spawnProcess: () => child,
|
||||
connectSocket: () => {
|
||||
queueMicrotask(() => socket.emit('connect'));
|
||||
return socket as never;
|
||||
},
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
const position = (data: number) =>
|
||||
socket.emit(
|
||||
'data',
|
||||
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data })}\n`,
|
||||
);
|
||||
try {
|
||||
await session.start({
|
||||
mediaPath: '/video/show.mkv',
|
||||
onPlaybackPosition: (time) => positions.push(time),
|
||||
});
|
||||
const playing = session.play(36, 42);
|
||||
await Promise.resolve();
|
||||
assert.equal(
|
||||
writes.some((line) => JSON.parse(line).command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
position(0);
|
||||
await playing;
|
||||
position(36);
|
||||
position(39);
|
||||
// No advancing position means no fabricated progress, even while audio is stalled.
|
||||
assert.deepEqual(positions, [36, 39]);
|
||||
position(41.9);
|
||||
assert.deepEqual(positions, [36, 39, 41.9]);
|
||||
await session.stop();
|
||||
position(42);
|
||||
assert.deepEqual(positions, [36, 39, 41.9]);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
for (const action of ['stop', 'dispose'] as const) {
|
||||
test(`${action} cancels a preview still waiting for media to load`, async () => {
|
||||
const { socket, writes } = createFakeSocket();
|
||||
const child = new ChildProcess();
|
||||
child.kill = () => true;
|
||||
const session = new MediaTimingPreviewSession({
|
||||
spawnProcess: () => child,
|
||||
connectSocket: () => {
|
||||
queueMicrotask(() => socket.emit('connect'));
|
||||
return socket as never;
|
||||
},
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
try {
|
||||
await session.start({ mediaPath: '/video/show.mkv' });
|
||||
const pending = assert.rejects(session.play(36, 42), /closed|cancelled/);
|
||||
await session[action]();
|
||||
await pending;
|
||||
socket.emit(
|
||||
'data',
|
||||
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
|
||||
);
|
||||
assert.equal(
|
||||
writes.some((line) => JSON.parse(line).command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
|
||||
const { socket, writes } = createFakeSocket();
|
||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||
@@ -253,11 +358,16 @@ test('preview session plays once to the clip end and reports when mpv has draine
|
||||
[
|
||||
['observe_property', 1, 'eof-reached'],
|
||||
['observe_property', 2, 'pause'],
|
||||
['observe_property', 3, 'time-pos'],
|
||||
],
|
||||
);
|
||||
// The observers' initial replies describe the idle paused player, not a finished preview.
|
||||
socket.emit('data', property('eof-reached', false) + property('pause', true));
|
||||
assert.equal(endedCount, 0);
|
||||
socket.emit(
|
||||
'data',
|
||||
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
|
||||
);
|
||||
|
||||
writes.length = 0;
|
||||
await session.play(12.25, 14.5);
|
||||
@@ -265,8 +375,8 @@ test('preview session plays once to the clip end and reports when mpv has draine
|
||||
writes.map((line) => JSON.parse(line).command),
|
||||
[
|
||||
['set_property', 'pause', true],
|
||||
['seek', 12.25, 'absolute+exact'],
|
||||
['set_property', 'end', '14.500'],
|
||||
['seek', 12.25, 'absolute+exact'],
|
||||
['set_property', 'pause', false],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ const CONNECT_RETRY_MS = 40;
|
||||
*/
|
||||
const EOF_OBSERVER_ID = 1;
|
||||
const PAUSE_OBSERVER_ID = 2;
|
||||
const POSITION_OBSERVER_ID = 3;
|
||||
const PLAYER_RESPONSE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface MediaTimingPreviewStartOptions {
|
||||
mediaPath: string;
|
||||
@@ -23,6 +25,7 @@ export interface MediaTimingPreviewStartOptions {
|
||||
volume?: number;
|
||||
/** The file keeps source timestamps (a cached remote window); seek with the original times. */
|
||||
absoluteTimestamps?: boolean;
|
||||
onPlaybackPosition?: (time: number) => void;
|
||||
}
|
||||
|
||||
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
|
||||
@@ -41,6 +44,7 @@ interface MediaTimingPreviewDeps {
|
||||
export function buildMediaTimingPreviewArgs(
|
||||
socketPath: string,
|
||||
options: MediaTimingPreviewStartOptions,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string[] {
|
||||
const args = [
|
||||
'--no-config',
|
||||
@@ -63,6 +67,12 @@ export function buildMediaTimingPreviewArgs(
|
||||
if (options.absoluteTimestamps) {
|
||||
args.push('--rebase-start-time=no');
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
// coreaudio can reject planar float (what Opus decodes to) on Bluetooth outputs such as
|
||||
// AirPods, and mpv then falls back to avfoundation. Its 2 s device buffer makes time-pos
|
||||
// updates sparse and ends the clip well before the audio does, so the preview cursor jumps.
|
||||
args.push('--audio-format=float');
|
||||
}
|
||||
args.push('--', options.mediaPath);
|
||||
return args;
|
||||
}
|
||||
@@ -103,8 +113,21 @@ export class MediaTimingPreviewSession {
|
||||
private disposed = false;
|
||||
private readBuffer = '';
|
||||
private playing = false;
|
||||
private playSequence = 0;
|
||||
private eofReached = false;
|
||||
private paused = true;
|
||||
private mediaReady = false;
|
||||
private onPlaybackPosition: MediaTimingPreviewStartOptions['onPlaybackPosition'];
|
||||
private nextRequestId = 0;
|
||||
private readonly pendingCommands = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
private readonly readyListeners = new Set<() => void>();
|
||||
private readonly pendingWaits = new Set<(error: Error) => void>();
|
||||
private readonly endedListeners = new Set<() => void>();
|
||||
|
||||
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
|
||||
@@ -141,6 +164,8 @@ export class MediaTimingPreviewSession {
|
||||
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
|
||||
const mediaPath = options.mediaPath.trim();
|
||||
if (!mediaPath) throw new Error('No media source is available for preview');
|
||||
this.mediaReady = false;
|
||||
this.onPlaybackPosition = options.onPlaybackPosition;
|
||||
|
||||
const socketPath = this.deps.createSocketPath();
|
||||
this.socketPath = socketPath;
|
||||
@@ -152,7 +177,7 @@ export class MediaTimingPreviewSession {
|
||||
this.startupError = null;
|
||||
const child = this.deps.spawnProcess(
|
||||
command,
|
||||
buildMediaTimingPreviewArgs(socketPath, { ...options, mediaPath }),
|
||||
buildMediaTimingPreviewArgs(socketPath, { ...options, mediaPath }, this.deps.platform),
|
||||
);
|
||||
this.process = child;
|
||||
child.once('error', (error) => {
|
||||
@@ -186,19 +211,35 @@ export class MediaTimingPreviewSession {
|
||||
}
|
||||
|
||||
this.playing = false;
|
||||
this.send(['set_property', 'pause', true]);
|
||||
this.send(['seek', startTime, 'absolute+exact']);
|
||||
const sequence = ++this.playSequence;
|
||||
if (!this.mediaReady) {
|
||||
await this.waitForPlayer((resolve) => {
|
||||
this.readyListeners.add(resolve);
|
||||
return () => this.readyListeners.delete(resolve);
|
||||
});
|
||||
}
|
||||
// The option parser wants a time string; a raw JSON number is not accepted for `end`.
|
||||
this.send(['set_property', 'end', endTime.toFixed(3)]);
|
||||
this.send(['set_property', 'pause', false]);
|
||||
// Only the seek's eof-reached=false and the later keep-open pause count for this play.
|
||||
const commands = [
|
||||
['set_property', 'pause', true],
|
||||
['set_property', 'end', endTime.toFixed(3)],
|
||||
['seek', startTime, 'absolute+exact'],
|
||||
['set_property', 'pause', false],
|
||||
];
|
||||
for (const command of commands) {
|
||||
if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled');
|
||||
await this.command(command);
|
||||
}
|
||||
if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled');
|
||||
// Discard paused/EOF observations from the previous clip before arming this playback.
|
||||
this.eofReached = false;
|
||||
this.paused = false;
|
||||
this.playing = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.playSequence += 1;
|
||||
this.playing = false;
|
||||
this.rejectPendingWaits();
|
||||
if (!this.socket || this.socket.destroyed) return;
|
||||
this.send(['set_property', 'pause', true]);
|
||||
}
|
||||
@@ -227,6 +268,23 @@ export class MediaTimingPreviewSession {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'request_id' in message &&
|
||||
typeof message.request_id === 'number'
|
||||
) {
|
||||
const pending = this.pendingCommands.get(message.request_id);
|
||||
if (pending) {
|
||||
if ('error' in message && message.error === 'success') pending.resolve();
|
||||
else
|
||||
pending.reject(
|
||||
new Error(
|
||||
`Preview command failed: ${'error' in message ? String(message.error) : 'unknown error'}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
@@ -241,6 +299,13 @@ export class MediaTimingPreviewSession {
|
||||
}
|
||||
|
||||
private handlePropertyChange(name: unknown, data: unknown): void {
|
||||
if (name === 'time-pos') {
|
||||
if (typeof data !== 'number' || !Number.isFinite(data)) return;
|
||||
this.mediaReady = true;
|
||||
for (const listener of this.readyListeners) listener();
|
||||
if (this.playing) this.onPlaybackPosition?.(data);
|
||||
return;
|
||||
}
|
||||
if (name === 'eof-reached') this.eofReached = data === true;
|
||||
else if (name === 'pause') this.paused = data === true;
|
||||
else return;
|
||||
@@ -255,6 +320,8 @@ export class MediaTimingPreviewSession {
|
||||
|
||||
private releaseResources(): void {
|
||||
this.cancelRetryWait();
|
||||
this.playing = false;
|
||||
this.rejectPendingWaits();
|
||||
try {
|
||||
this.send(['quit']);
|
||||
} catch {
|
||||
@@ -283,6 +350,57 @@ export class MediaTimingPreviewSession {
|
||||
this.socket.write(`${JSON.stringify({ command })}\n`);
|
||||
}
|
||||
|
||||
private waitForPlayer(
|
||||
subscribe: (resolve: () => void, reject: (error: Error) => void) => () => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let unsubscribe = () => {};
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.deps.cancelSchedule(timeout);
|
||||
unsubscribe();
|
||||
this.pendingWaits.delete(fail);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const fail = (error: Error) => finish(error);
|
||||
const timeout = this.deps.schedule(
|
||||
() => fail(new Error('Timed out waiting for the preview player')),
|
||||
PLAYER_RESPONSE_TIMEOUT_MS,
|
||||
);
|
||||
this.pendingWaits.add(fail);
|
||||
try {
|
||||
unsubscribe = subscribe(() => finish(), fail);
|
||||
if (settled) unsubscribe();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private command(command: Array<string | number | boolean>): Promise<void> {
|
||||
if (!this.socket || this.socket.destroyed)
|
||||
return Promise.reject(new Error('Preview player is not ready'));
|
||||
const socket = this.socket;
|
||||
const requestId = ++this.nextRequestId;
|
||||
return this.waitForPlayer((resolve, reject) => {
|
||||
this.pendingCommands.set(requestId, { resolve, reject });
|
||||
try {
|
||||
socket.write(`${JSON.stringify({ command, request_id: requestId })}\n`);
|
||||
} catch (error) {
|
||||
this.pendingCommands.delete(requestId);
|
||||
throw error;
|
||||
}
|
||||
return () => this.pendingCommands.delete(requestId);
|
||||
});
|
||||
}
|
||||
|
||||
private rejectPendingWaits(): void {
|
||||
for (const reject of this.pendingWaits) reject(new Error('Preview player is closed'));
|
||||
}
|
||||
|
||||
private async connectWithRetry(socketPath: string): Promise<void> {
|
||||
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
|
||||
while (!this.disposed && this.deps.now() < deadline) {
|
||||
@@ -305,9 +423,13 @@ export class MediaTimingPreviewSession {
|
||||
socket.on('data', (chunk: Buffer | string) => {
|
||||
if (this.socket === socket) this.handleSocketData(chunk);
|
||||
});
|
||||
socket.once('close', () => this.finishPlayback());
|
||||
socket.once('close', () => {
|
||||
this.rejectPendingWaits();
|
||||
this.finishPlayback();
|
||||
});
|
||||
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
|
||||
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
|
||||
this.send(['observe_property', POSITION_OBSERVER_ID, 'time-pos']);
|
||||
return;
|
||||
} catch {
|
||||
if (this.disposed) {
|
||||
|
||||
@@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
@@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: (message, err) => logs.push({ message, err }),
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
@@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
|
||||
assert.equal((logs[0]?.err as Error).message, 'mine boom');
|
||||
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
|
||||
assert.equal(cardsMined, 0);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
const osd: string[] = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => ['one', 'two'],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: (text) =>
|
||||
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
|
||||
},
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => true,
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: () => {},
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
|
||||
@@ -201,7 +201,6 @@ export function handleMineSentenceDigit(
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined?: (count: number) => void;
|
||||
},
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
@@ -233,14 +232,9 @@ export function handleMineSentenceDigit(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
const cardsToMine = 1;
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
deps.logError('mineSentenceMultiple failed:', err);
|
||||
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
|
||||
|
||||
@@ -39,6 +39,7 @@ test('non-macOS modal overlay remains a regular window', () => {
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ export function buildOverlayWindowOptions(
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundColor: '#00000000',
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||
skipTaskbar: true,
|
||||
resizable: shouldAllowCompositorResize,
|
||||
|
||||
@@ -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] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function buildStatsWindowOptions(options: {
|
||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
transparent: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
|
||||
@@ -57,6 +57,7 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('stats panels present after document load on macOS', () => {
|
||||
|
||||
@@ -640,3 +640,116 @@ test('configureEarlyAppPaths creates a fresh macOS config directory before Elect
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths isolates development runs from the production profile', () => {
|
||||
const calls: string[] = [];
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-dev-profile-'));
|
||||
const devProfileDir = path.posix.join(xdgConfigHome, 'SubMiner-dev');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: (name) => calls.push(`name:${name}`),
|
||||
setPath: (key, value) => calls.push(`path:${key}:${value}`),
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: xdgConfigHome,
|
||||
xdgConfigHome,
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--start', '--dev'],
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, devProfileDir);
|
||||
assert.deepEqual(calls, ['name:SubMiner', `path:userData:${devProfileDir}`]);
|
||||
} finally {
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => {
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-mpv-flags-'));
|
||||
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
|
||||
try {
|
||||
for (const forwardedFlag of ['--dev', '--debug']) {
|
||||
let selectedPath = '';
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: () => {},
|
||||
setPath: (_key, value) => {
|
||||
selectedPath = value;
|
||||
},
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: xdgConfigHome,
|
||||
xdgConfigHome,
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--launch-mpv', forwardedFlag],
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.equal(selectedPath, configDir);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths uses the supplied environment for config discovery', () => {
|
||||
const paths: string[] = [];
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-injected-env-'));
|
||||
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: () => {},
|
||||
setPath: (_key, value) => paths.push(value),
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: path.posix.join(xdgConfigHome, 'home'),
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--start'],
|
||||
env: { XDG_CONFIG_HOME: xdgConfigHome },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.deepEqual(paths, [configDir]);
|
||||
} finally {
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths allows an explicit production-profile development run', () => {
|
||||
const paths: string[] = [];
|
||||
const configBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-prod-profile-'));
|
||||
// Use the host platform so the selected directory is a real path mkdirSync can create.
|
||||
const configDir = path.join(configBaseDir, 'SubMiner');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: () => {},
|
||||
setPath: (_key, value) => paths.push(value),
|
||||
},
|
||||
{
|
||||
platform: process.platform,
|
||||
appDataDir: configBaseDir,
|
||||
xdgConfigHome: configBaseDir,
|
||||
homeDir: configBaseDir,
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--debug'],
|
||||
env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.deepEqual(paths, [configDir]);
|
||||
} finally {
|
||||
fs.rmSync(configBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
@@ -14,6 +15,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||
const APP_NAME = 'SubMiner';
|
||||
const DEVELOPMENT_APP_NAME = 'SubMiner-dev';
|
||||
export const USE_PRODUCTION_PROFILE_ENV = 'SUBMINER_USE_PRODUCTION_PROFILE';
|
||||
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
||||
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
@@ -53,6 +56,8 @@ type EarlyAppPathOptions = {
|
||||
xdgConfigHome?: string;
|
||||
homeDir?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||
@@ -252,13 +257,25 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
||||
}
|
||||
|
||||
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
||||
const userDataPath = resolveConfigDir({
|
||||
platform: options?.platform ?? process.platform,
|
||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const env = options?.env ?? process.env;
|
||||
const configDir = resolveConfigDir({
|
||||
platform,
|
||||
appDataDir: options?.appDataDir ?? env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
|
||||
homeDir: options?.homeDir ?? os.homedir(),
|
||||
existsSync: options?.existsSync ?? fs.existsSync,
|
||||
});
|
||||
const argv = options?.argv ?? process.argv;
|
||||
const launchMpvIndex = argv.indexOf('--launch-mpv');
|
||||
const appArgv = launchMpvIndex === -1 ? argv : argv.slice(0, launchMpvIndex);
|
||||
const useDevelopmentProfile =
|
||||
(appArgv.includes('--dev') || appArgv.includes('--debug')) &&
|
||||
env[USE_PRODUCTION_PROFILE_ENV] !== '1';
|
||||
const platformPath = platform === 'win32' ? path.win32 : path.posix;
|
||||
const userDataPath = useDevelopmentProfile
|
||||
? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME)
|
||||
: configDir;
|
||||
|
||||
// The entry process requests its singleton lock before main-process config bootstrap.
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
|
||||
+20
-2
@@ -22,7 +22,10 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import {
|
||||
requestSingleInstanceLockEarly,
|
||||
shouldBypassSingleInstanceLockForArgv,
|
||||
} from './main/early-single-instance';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
@@ -35,6 +38,7 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
import { handleSyncCliAtEntry } from './main/sync-cli';
|
||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
|
||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
applyLogFileTogglesToEnv,
|
||||
@@ -192,11 +196,25 @@ registerFatalErrorHandlers({
|
||||
});
|
||||
|
||||
function startMainProcess(): void {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
// Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon
|
||||
// commands keep their existing lock bypass when Electron runs in Node mode.
|
||||
const gotSingleInstanceLock =
|
||||
shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeGuard = enforceElectronRuntimeGuard({
|
||||
electronVersion: process.versions.electron ?? '',
|
||||
userDataPath,
|
||||
});
|
||||
if (!runtimeGuard.ok) {
|
||||
console.error(runtimeGuard.details);
|
||||
dialog.showErrorBox(runtimeGuard.title, runtimeGuard.details);
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
|
||||
+30
-10
@@ -38,6 +38,10 @@ import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import {
|
||||
assertYomitanDictionaryMutationSafe,
|
||||
observeYomitanDictionaryCount,
|
||||
} from './main/runtime/yomitan-dictionary-integrity';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||
import {
|
||||
@@ -772,6 +776,7 @@ type BootServices = MainBootServicesResult<
|
||||
const bootServices = createMainBootServices({
|
||||
platform: process.platform,
|
||||
argv: process.argv,
|
||||
configDir: app.getPath('userData'),
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
@@ -1480,6 +1485,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
});
|
||||
const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length);
|
||||
if (!integrity.safe) {
|
||||
logger.error(`[dictionary:integrity] ${integrity.message}`);
|
||||
}
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () =>
|
||||
@@ -2627,10 +2636,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
),
|
||||
getYomitanDictionaryInfo: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
});
|
||||
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
|
||||
return dictionaries;
|
||||
},
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
@@ -3009,6 +3020,13 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
}
|
||||
}
|
||||
},
|
||||
onPreviewPosition: (position) => {
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
|
||||
}
|
||||
}
|
||||
},
|
||||
showStatus: (message) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
});
|
||||
@@ -3066,9 +3084,15 @@ function openSubtitleGenerationOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
// currentMediaPath follows every mpv path change and is cleared on disconnect; the
|
||||
// client's currentVideoPath outlives the connection, so a reconnect could reuse it.
|
||||
function isMediaPlaybackActive(): boolean {
|
||||
return appState.mpvClient?.connected === true && Boolean(appState.currentMediaPath?.trim());
|
||||
}
|
||||
|
||||
function openSessionHelpOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSessionHelpModalRuntime,
|
||||
(deps) => openSessionHelpModalRuntime({ ...deps, isMediaPlaybackActive }),
|
||||
'Session help overlay unavailable.',
|
||||
'Failed to open session help overlay.',
|
||||
);
|
||||
@@ -5341,6 +5365,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({
|
||||
@@ -5471,10 +5497,6 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
|
||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
|
||||
mineSentenceCardCore,
|
||||
recordCardsMined: (count, noteIds) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
},
|
||||
});
|
||||
const mineSentenceCardHandler = createMineSentenceCardHandler(
|
||||
buildMineSentenceCardMainDepsHandler(),
|
||||
@@ -5507,10 +5529,6 @@ const buildHandleMineSentenceDigitMainDepsHandler =
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
onCardsMined: (cards) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(cards);
|
||||
},
|
||||
handleMineSentenceDigitCore,
|
||||
});
|
||||
const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler();
|
||||
@@ -6792,6 +6810,8 @@ const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||
'youtube-whisper.ja.srt',
|
||||
),
|
||||
}),
|
||||
resolveYoutubeSourceUrl: (mediaPath) =>
|
||||
youtubePlaybackRuntime.getYoutubeSourceUrlForStream(mediaPath),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
onProgress: (progress) => {
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
|
||||
@@ -8,18 +8,31 @@ import {
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
resolveAppImageMountKeepaliveInvocation,
|
||||
resolveMountFsType,
|
||||
} from './appimage-mount-keepalive';
|
||||
|
||||
const FUSE_EXEC_PATH = '/tmp/.mount_SubMinAb12Cd/SubMiner';
|
||||
const MOUNT_INFO = [
|
||||
'1 0 0:1 / / rw,relatime - btrfs /dev/nvme0n1p2 rw',
|
||||
'773 75 0:95 / /tmp/.mount_SubMinAb12Cd ro,nosuid shared:809 - fuse.SubMiner.AppImage SubMiner.AppImage ro',
|
||||
'1238 1154 7:5 / /run/firejail/appimage ro,nosuid - squashfs /dev/loop5 ro',
|
||||
'900 75 0:99 / /mnt/with\\040space rw - ext4 /dev/sdb1 rw',
|
||||
].join('\n');
|
||||
|
||||
function resolveOnFuse(env: NodeJS.ProcessEnv, platform: NodeJS.Platform) {
|
||||
return resolveAppImageMountKeepaliveInvocation(env, platform, FUSE_EXEC_PATH, () => MOUNT_INFO);
|
||||
}
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation is linux-only', () => {
|
||||
const env = { APPIMAGE: '/opt/SubMiner.AppImage' };
|
||||
assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'win32'), null);
|
||||
assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'darwin'), null);
|
||||
assert.notEqual(resolveAppImageMountKeepaliveInvocation(env, 'linux'), null);
|
||||
assert.equal(resolveOnFuse(env, 'win32'), null);
|
||||
assert.equal(resolveOnFuse(env, 'darwin'), null);
|
||||
assert.notEqual(resolveOnFuse(env, 'linux'), null);
|
||||
});
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation requires APPIMAGE env', () => {
|
||||
assert.equal(resolveAppImageMountKeepaliveInvocation({}, 'linux'), null);
|
||||
assert.equal(resolveAppImageMountKeepaliveInvocation({ APPIMAGE: ' ' }, 'linux'), null);
|
||||
assert.equal(resolveOnFuse({}, 'linux'), null);
|
||||
assert.equal(resolveOnFuse({ APPIMAGE: ' ' }, 'linux'), null);
|
||||
});
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation honors disable env', () => {
|
||||
@@ -27,14 +40,47 @@ test('resolveAppImageMountKeepaliveInvocation honors disable env', () => {
|
||||
APPIMAGE: '/opt/SubMiner.AppImage',
|
||||
SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE: '1',
|
||||
};
|
||||
assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'linux'), null);
|
||||
assert.equal(resolveOnFuse(env, 'linux'), null);
|
||||
});
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation skips kernel squashfs mounts owned by a sandbox', () => {
|
||||
const env = { APPIMAGE: '/tmp/SubMiner.AppImage' };
|
||||
assert.equal(
|
||||
resolveAppImageMountKeepaliveInvocation(
|
||||
env,
|
||||
'linux',
|
||||
'/run/firejail/appimage/SubMiner',
|
||||
() => MOUNT_INFO,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.notEqual(
|
||||
resolveAppImageMountKeepaliveInvocation(
|
||||
env,
|
||||
'linux',
|
||||
'/tmp/appimage_extracted_42c4346b/SubMiner',
|
||||
() => MOUNT_INFO,
|
||||
),
|
||||
null,
|
||||
'extract-and-run directories are deleted with the bootstrap, so they keep the supervisor',
|
||||
);
|
||||
assert.notEqual(
|
||||
resolveAppImageMountKeepaliveInvocation(env, 'linux', FUSE_EXEC_PATH, () => null),
|
||||
null,
|
||||
'unknown mount type keeps the supervisor',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveMountFsType picks the longest containing mount point', () => {
|
||||
assert.equal(resolveMountFsType(FUSE_EXEC_PATH, MOUNT_INFO), 'fuse.SubMiner.AppImage');
|
||||
assert.equal(resolveMountFsType('/run/firejail/appimage/SubMiner', MOUNT_INFO), 'squashfs');
|
||||
assert.equal(resolveMountFsType('/mnt/with space/SubMiner', MOUNT_INFO), 'ext4');
|
||||
assert.equal(resolveMountFsType('/tmp/.mount_SubMinAb12CdX/SubMiner', MOUNT_INFO), 'btrfs');
|
||||
assert.equal(resolveMountFsType('/usr/bin/sh', ''), null);
|
||||
});
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation builds sh invocation with AppImage path', () => {
|
||||
const invocation = resolveAppImageMountKeepaliveInvocation(
|
||||
{ APPIMAGE: '/opt/SubMiner.AppImage' },
|
||||
'linux',
|
||||
);
|
||||
const invocation = resolveOnFuse({ APPIMAGE: '/opt/SubMiner.AppImage' }, 'linux');
|
||||
assert.ok(invocation);
|
||||
assert.equal(invocation.command, '/bin/sh');
|
||||
assert.deepEqual(invocation.args, [
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
// AppImage via `--appimage-mount` (holder process keeps the mount alive), runs
|
||||
// AppRun from that mount, and after the app exits waits until no process is still
|
||||
// executing from the mount before releasing the holder.
|
||||
//
|
||||
// A kernel squashfs mount is the exception: whoever mounted it owns its lifetime,
|
||||
// not the runtime. `firejail --appimage` (used by the AppImage catalog test)
|
||||
// loop-mounts the image for the sandbox's lifetime and sets NoNewPrivs, so FUSE
|
||||
// cannot mount there and the supervisor could never start the detached app.
|
||||
// Extract-and-run directories are NOT exempt: the runtime deletes them when the
|
||||
// bootstrap exits, so those still need the supervisor's re-run.
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
export interface AppImageMountKeepaliveInvocation {
|
||||
command: string;
|
||||
@@ -60,14 +69,51 @@ kill "$holder" 2>/dev/null
|
||||
exit "$rc"
|
||||
`;
|
||||
|
||||
function unescapeMountInfoPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/g, (_, octal: string) =>
|
||||
String.fromCharCode(parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
// Filesystem type of the mount containing `filePath`, from /proc/<pid>/mountinfo
|
||||
// text. Null when no mount matches.
|
||||
export function resolveMountFsType(filePath: string, mountInfo: string): string | null {
|
||||
let best: { mountPoint: string; fsType: string } | null = null;
|
||||
for (const line of mountInfo.split('\n')) {
|
||||
const [mountFields, fsFields] = line.split(' - ');
|
||||
const mountPointField = mountFields?.split(' ')[4];
|
||||
const fsType = fsFields?.split(' ')[0];
|
||||
if (!mountPointField || !fsType) continue;
|
||||
const mountPoint = unescapeMountInfoPath(mountPointField);
|
||||
const contains =
|
||||
mountPoint === '/' || filePath === mountPoint || filePath.startsWith(`${mountPoint}/`);
|
||||
if (contains && (!best || mountPoint.length >= best.mountPoint.length)) {
|
||||
best = { mountPoint, fsType };
|
||||
}
|
||||
}
|
||||
return best?.fsType ?? null;
|
||||
}
|
||||
|
||||
function readSelfMountInfo(): string | null {
|
||||
try {
|
||||
return fs.readFileSync('/proc/self/mountinfo', 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAppImageMountKeepaliveInvocation(
|
||||
env: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
execPath: string = process.execPath,
|
||||
readMountInfo: () => string | null = readSelfMountInfo,
|
||||
): AppImageMountKeepaliveInvocation | null {
|
||||
if (platform !== 'linux') return null;
|
||||
if (env[DISABLE_ENV] === '1') return null;
|
||||
const appImagePath = env.APPIMAGE?.trim();
|
||||
if (!appImagePath) return null;
|
||||
const mountInfo = readMountInfo();
|
||||
if (mountInfo !== null && resolveMountFsType(execPath, mountInfo) === 'squashfs') return null;
|
||||
return {
|
||||
command: '/bin/sh',
|
||||
args: ['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath],
|
||||
|
||||
@@ -138,3 +138,56 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||
});
|
||||
|
||||
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
|
||||
const services = createMainBootServices({
|
||||
platform: 'linux',
|
||||
argv: ['electron', '.', '--dev'],
|
||||
configDir: '/tmp/SubMiner-dev',
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: undefined,
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => {
|
||||
throw new Error('early profile should be authoritative');
|
||||
},
|
||||
existsSync: () => false,
|
||||
mkdirSync: () => {},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
app: {
|
||||
setPath: () => {},
|
||||
quit: () => {},
|
||||
exit: () => {},
|
||||
on: () => ({}),
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {},
|
||||
onConfigStartupParseError: () => {},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({}),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({}),
|
||||
createAppState: (input) => input,
|
||||
});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/SubMiner-dev');
|
||||
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
|
||||
});
|
||||
@@ -31,6 +31,7 @@ export interface MainBootServicesParams<
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
configDir?: string;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
@@ -174,13 +175,15 @@ export function createMainBootServices<
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
const configDir = params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const configDir =
|
||||
params.configDir ??
|
||||
params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const userDataPath = configDir;
|
||||
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
|
||||
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
|
||||
|
||||
function withTempDir(run: (directory: string) => void): void {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
|
||||
try {
|
||||
run(directory);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('runtime guard major matches the pinned Electron dependency', () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||
) as { devDependencies: { electron: string } };
|
||||
|
||||
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
|
||||
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
|
||||
});
|
||||
|
||||
test('runtime guard records the supported Electron major', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.4.1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard rejects a runtime outside the build major without writing state', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '44.0.0',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Unsupported Electron runtime');
|
||||
assert.match(result.details, /requires Electron 43/);
|
||||
assert.equal(fs.existsSync(result.statePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard rejects prerelease Electron versions without writing state', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1-beta.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'SubMiner could not verify Electron');
|
||||
assert.equal(fs.existsSync(result.statePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.match(result.details, /destroy Yomitan dictionaries/);
|
||||
assert.equal(result.details.includes(statePath), true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||
highestElectronMajor: 44,
|
||||
lastElectronVersion: '44.1.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks a minor downgrade even when the patch is higher', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.3.99',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.equal(result.details.includes(statePath), true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.4.1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard allows a patch downgrade while preserving minor downgrade protection', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.7.3' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.7.2',
|
||||
userDataPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const updatedState = fs.readFileSync(statePath, 'utf8');
|
||||
assert.deepEqual(JSON.parse(updatedState), {
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.7.2',
|
||||
});
|
||||
|
||||
const minorDowngrade = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.6.99',
|
||||
userDataPath,
|
||||
});
|
||||
assert.equal(minorDowngrade.ok, false);
|
||||
if (minorDowngrade.ok) return;
|
||||
assert.equal(minorDowngrade.title, 'Electron downgrade blocked');
|
||||
assert.equal(fs.readFileSync(statePath, 'utf8'), updatedState);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
const previousState = JSON.stringify({
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.7.2',
|
||||
});
|
||||
fs.writeFileSync(statePath, previousState, 'utf8');
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '42.11.8',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 42,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.equal(fs.readFileSync(statePath, 'utf8'), previousState);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard fails closed when its safety record is malformed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'SubMiner profile safety check failed');
|
||||
assert.match(result.details, /invalid format/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
|
||||
|
||||
export const SUPPORTED_ELECTRON_MAJOR = 43;
|
||||
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
|
||||
|
||||
type ElectronRuntimeState = {
|
||||
highestElectronMajor: number;
|
||||
lastElectronVersion: string;
|
||||
};
|
||||
|
||||
type ParsedElectronVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
type ValidatedElectronRuntimeState = {
|
||||
state: ElectronRuntimeState;
|
||||
version: ParsedElectronVersion;
|
||||
};
|
||||
|
||||
export type ElectronRuntimeGuardResult =
|
||||
| { ok: true; statePath: string }
|
||||
| { ok: false; title: string; details: string; statePath: string };
|
||||
|
||||
function parseElectronVersion(version: string): ParsedElectronVersion | null {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const major = Number.parseInt(match[1]!, 10);
|
||||
const minor = Number.parseInt(match[2]!, 10);
|
||||
const patch = Number.parseInt(match[3]!, 10);
|
||||
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
|
||||
return null;
|
||||
}
|
||||
if (major === 0) return null;
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
function compareElectronMajorMinorVersions(
|
||||
left: ParsedElectronVersion,
|
||||
right: ParsedElectronVersion,
|
||||
): number {
|
||||
return left.major - right.major || left.minor - right.minor;
|
||||
}
|
||||
|
||||
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
|
||||
if (!fs.existsSync(statePath)) return null;
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
|
||||
const highestElectronMajor = parsed.highestElectronMajor;
|
||||
const lastElectronVersion =
|
||||
typeof parsed.lastElectronVersion === 'string'
|
||||
? parseElectronVersion(parsed.lastElectronVersion)
|
||||
: null;
|
||||
if (
|
||||
typeof highestElectronMajor !== 'number' ||
|
||||
!Number.isSafeInteger(highestElectronMajor) ||
|
||||
highestElectronMajor <= 0 ||
|
||||
lastElectronVersion === null
|
||||
) {
|
||||
throw new Error('The runtime safety record has an invalid format.');
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
highestElectronMajor,
|
||||
lastElectronVersion: parsed.lastElectronVersion!,
|
||||
},
|
||||
version: lastElectronVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
|
||||
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function enforceElectronRuntimeGuard(options: {
|
||||
electronVersion: string;
|
||||
userDataPath: string;
|
||||
supportedElectronMajor?: number;
|
||||
}): ElectronRuntimeGuardResult {
|
||||
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
|
||||
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
|
||||
const currentVersion = parseElectronVersion(options.electronVersion);
|
||||
|
||||
if (currentVersion === null) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'SubMiner could not verify Electron',
|
||||
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentVersion.major !== supportedElectronMajor) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Unsupported Electron runtime',
|
||||
details: [
|
||||
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
|
||||
`The current runtime is Electron ${options.electronVersion}.`,
|
||||
'',
|
||||
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
let previousState: ValidatedElectronRuntimeState | null;
|
||||
try {
|
||||
previousState = readRuntimeState(statePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'SubMiner profile safety check failed',
|
||||
details: [
|
||||
`SubMiner could not read the runtime safety record at ${statePath}.`,
|
||||
(error as Error).message,
|
||||
'',
|
||||
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
previousState &&
|
||||
(currentVersion.major < previousState.state.highestElectronMajor ||
|
||||
compareElectronMajorMinorVersions(currentVersion, previousState.version) < 0)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Electron downgrade blocked',
|
||||
details: [
|
||||
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
|
||||
`The current runtime is Electron ${options.electronVersion}.`,
|
||||
`Runtime safety record: ${statePath}.`,
|
||||
'',
|
||||
'Opening Chromium storage with an older Electron major or minor version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile. Patch downgrades within the same major and minor version are allowed.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
writeRuntimeState(statePath, {
|
||||
highestElectronMajor: Math.max(
|
||||
currentVersion.major,
|
||||
previousState?.state.highestElectronMajor ?? 0,
|
||||
),
|
||||
lastElectronVersion: options.electronVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'SubMiner profile safety check failed',
|
||||
details: [
|
||||
`SubMiner could not update the runtime safety record at ${statePath}.`,
|
||||
(error as Error).message,
|
||||
'',
|
||||
'Yomitan storage was not loaded.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, statePath };
|
||||
}
|
||||
@@ -67,7 +67,6 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||
@@ -81,16 +80,6 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
mpvClient: { connected: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
mine.recordCardsMined(1);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:x',
|
||||
'update',
|
||||
'fg:fg',
|
||||
'trigger',
|
||||
'audio:a',
|
||||
'mark',
|
||||
'mine:m',
|
||||
'cards:1',
|
||||
]);
|
||||
assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
|
||||
});
|
||||
@@ -80,7 +80,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
@@ -95,6 +94,5 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
});
|
||||
}
|
||||
@@ -63,7 +63,7 @@ test('trigger and mark handlers delegate to core services', async () => {
|
||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||
});
|
||||
|
||||
test('mine sentence handler records mined cards only when core returns true', async () => {
|
||||
test('mine sentence handler leaves card counting to card creation', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const mpvClient = {};
|
||||
@@ -78,14 +78,13 @@ test('mine sentence handler records mined cards only when core returns true', as
|
||||
options.showMpvOsd('mine');
|
||||
return created;
|
||||
},
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
});
|
||||
|
||||
created = false;
|
||||
await mineSentenceCard();
|
||||
created = true;
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
|
||||
});
|
||||
|
||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||
@@ -99,7 +98,6 @@ test('mine sentence handler forwards the canonical primary subtitle snapshot', a
|
||||
assert.equal(options.primarySubtitle, primarySubtitle);
|
||||
return true;
|
||||
},
|
||||
recordCardsMined: () => {},
|
||||
});
|
||||
|
||||
await mineSentenceCard();
|
||||
|
||||
@@ -83,18 +83,15 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
return async (): Promise<void> => {
|
||||
const primarySubtitle = deps.getPrimarySubtitle?.();
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
...(primarySubtitle ? { primarySubtitle } : {}),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
deps.recordCardsMined(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { beforeEach, afterEach } from 'node:test';
|
||||
import {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
updateLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
|
||||
} from './linux-mpv-fullscreen-overlay-refresh';
|
||||
|
||||
const compositorEnvKeys = [
|
||||
'HYPRLAND_INSTANCE_SIGNATURE',
|
||||
'SWAYSOCK',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_DESKTOP',
|
||||
] as const;
|
||||
const originalCompositorEnv = compositorEnvKeys.map((key) => [key, process.env[key]] as const);
|
||||
beforeEach(() => {
|
||||
for (const key of compositorEnvKeys) delete process.env[key];
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [key, value] of originalCompositorEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
for (const { compositorKey, expectedRefreshCalls } of [
|
||||
{
|
||||
compositorKey: 'HYPRLAND_INSTANCE_SIGNATURE',
|
||||
expectedRefreshCalls: ['mode', 'visibility', 'mouse', 'restack'],
|
||||
},
|
||||
{
|
||||
compositorKey: 'SWAYSOCK',
|
||||
expectedRefreshCalls: ['mode', 'visibility', 'hide', 'showInactive', 'mouse', 'restack'],
|
||||
},
|
||||
]) {
|
||||
test(`${compositorKey} fullscreen refresh uses compositor-specific restacking`, async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
process.env[compositorKey] = 'fullscreen-refresh-test';
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () => ({
|
||||
hide: () => calls.push('hide'),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setIgnoreMouseEvents: () => calls.push('mouse'),
|
||||
}),
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('visibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: () => calls.push('mode'),
|
||||
ensureOverlayWindowLevel: () => calls.push('restack'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
assert.deepEqual(calls, Array.from({ length: 5 }, () => expectedRefreshCalls).flat());
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
|
||||
@@ -52,8 +52,12 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
// Hyprland placement can restack in place. Remapping the overlay can
|
||||
// take focus from mpv and make Hyprland cancel the fullscreen transition.
|
||||
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) {
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
}
|
||||
if (deps.getOverlayInteractionActive?.() === true) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
|
||||
@@ -410,14 +410,17 @@ test('media timing review downloads one window of a remote stream for the wavefo
|
||||
endTime: 14.5,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewStarts, [
|
||||
{
|
||||
mediaPath: '/tmp/window-7.5-14.5.mkv',
|
||||
executablePath: 'mpv',
|
||||
volume: 60,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
|
||||
[
|
||||
{
|
||||
mediaPath: '/tmp/window-7.5-14.5.mkv',
|
||||
executablePath: 'mpv',
|
||||
volume: 60,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
|
||||
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']);
|
||||
});
|
||||
@@ -512,9 +515,10 @@ test('media timing review falls back to the remote stream after one failed windo
|
||||
audioStreamIndex: 2,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewStarts, [
|
||||
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
|
||||
[{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 }],
|
||||
);
|
||||
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
|
||||
});
|
||||
|
||||
@@ -981,7 +985,8 @@ test('disposing owns a preview session whose startup is still pending', async ()
|
||||
|
||||
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
|
||||
const endedReviewIds: string[] = [];
|
||||
const playback: { ended?: () => void } = {};
|
||||
const positions: Array<{ reviewId: string; time: number }> = [];
|
||||
const playback: { ended?: () => void; position?: (time: number) => void } = {};
|
||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||
publishPayload = resolve;
|
||||
@@ -997,7 +1002,9 @@ test('media timing review forwards the hidden player finishing a preview to the
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
start: async (options) => {
|
||||
playback.position = options.onPlaybackPosition;
|
||||
},
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: (listener) => {
|
||||
@@ -1012,6 +1019,7 @@ test('media timing review forwards the hidden player finishing a preview to the
|
||||
onPreviewEnded: (reviewId) => {
|
||||
endedReviewIds.push(reviewId);
|
||||
},
|
||||
onPreviewPosition: (position) => positions.push(position),
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const pendingDecision = runtime.requestReview({
|
||||
@@ -1031,6 +1039,9 @@ test('media timing review forwards the hidden player finishing a preview to the
|
||||
},
|
||||
);
|
||||
assert.ok(playback.ended);
|
||||
assert.ok(playback.position);
|
||||
playback.position(11);
|
||||
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
|
||||
playback.ended();
|
||||
assert.deepEqual(endedReviewIds, [payload.reviewId]);
|
||||
|
||||
@@ -1038,6 +1049,8 @@ test('media timing review forwards the hidden player finishing a preview to the
|
||||
await pendingDecision;
|
||||
playback.ended();
|
||||
assert.deepEqual(endedReviewIds, [payload.reviewId]);
|
||||
playback.position(12);
|
||||
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
|
||||
});
|
||||
|
||||
test('preview reports a stale review when the review ends during playback', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewPreviewPosition,
|
||||
MediaTimingReviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
MediaTimingReviewFrameRequest,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
MediaTimingReviewWaveformResult,
|
||||
} from '../../types/anki';
|
||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||
import type { MediaTimingPreviewStartOptions } from '../../core/services/media-timing-preview';
|
||||
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
|
||||
import {
|
||||
isRemoteMediaWindowSourcePath,
|
||||
@@ -36,13 +38,7 @@ interface ReviewMpvClient {
|
||||
}
|
||||
|
||||
interface PreviewSession {
|
||||
start(options: {
|
||||
mediaPath: string;
|
||||
executablePath?: string;
|
||||
audioTrackId?: number;
|
||||
volume?: number;
|
||||
absoluteTimestamps?: boolean;
|
||||
}): Promise<void>;
|
||||
start(options: MediaTimingPreviewStartOptions): Promise<void>;
|
||||
play(startTime: number, endTime: number): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Fires when the player reaches the end of the clip started by play(). */
|
||||
@@ -113,6 +109,7 @@ export interface MediaTimingReviewRuntimeDeps {
|
||||
openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
|
||||
/** Tells the modal that the hidden player finished the previewed clip. */
|
||||
onPreviewEnded?: (reviewId: string) => void;
|
||||
onPreviewPosition?: (position: MediaTimingReviewPreviewPosition) => void;
|
||||
showStatus: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -354,6 +351,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
await session.start({
|
||||
mediaPath,
|
||||
onPlaybackPosition: (time) => {
|
||||
if (active === review && review.preview?.session === started) {
|
||||
deps.onPreviewPosition?.({ reviewId: review.payload.reviewId, time });
|
||||
}
|
||||
},
|
||||
...previewOptions,
|
||||
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
|
||||
...(window
|
||||
|
||||
@@ -45,20 +45,17 @@ test('mining action main deps builders map callbacks', () => {
|
||||
getCurrentSecondarySubText: () => 'sub',
|
||||
showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: () => calls.push('mine-digit'),
|
||||
})();
|
||||
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
|
||||
mineDigit.showMpvOsd('done');
|
||||
mineDigit.logError('bad', null);
|
||||
mineDigit.onCardsMined(2);
|
||||
mineDigit.handleMineSentenceDigitCore(2, {
|
||||
subtitleTimingTracker: { track: true },
|
||||
ankiIntegration: { enabled: true },
|
||||
getCurrentSecondarySubText: () => 'sub',
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
onCardsMined: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
@@ -70,7 +67,6 @@ test('mining action main deps builders map callbacks', () => {
|
||||
'copy-current',
|
||||
'mine-osd:done',
|
||||
'err:bad',
|
||||
'cards:2',
|
||||
'mine-digit',
|
||||
]);
|
||||
});
|
||||
@@ -57,7 +57,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -66,7 +65,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
@@ -76,7 +74,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
onCardsMined: (count: number) => deps.onCardsMined(count),
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -85,7 +82,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => deps.handleMineSentenceDigitCore(count, options),
|
||||
});
|
||||
|
||||
@@ -53,7 +53,6 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
||||
getCurrentSecondarySubText: () => 'secondary',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: (count, options) => {
|
||||
assert.equal(count, 4);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
@@ -61,10 +60,9 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
||||
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
||||
options.showMpvOsd('mine');
|
||||
options.logError('boom', new Error('x'));
|
||||
options.onCardsMined(2);
|
||||
},
|
||||
});
|
||||
|
||||
handleMineSentenceDigit(4);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom']);
|
||||
});
|
||||
@@ -48,7 +48,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -57,7 +56,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
@@ -68,7 +66,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
logError: deps.logError,
|
||||
onCardsMined: deps.onCardsMined,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openSessionHelpModal } from './session-help-open';
|
||||
|
||||
test('session help open tells the renderer whether commands can run', async () => {
|
||||
for (const playing of [true, false]) {
|
||||
const sent: Array<{ channel: string; payload: unknown }> = [];
|
||||
const opened = await openSessionHelpModal({
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: (channel, payload) => {
|
||||
sent.push({ channel, payload });
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => true,
|
||||
logWarn: () => {},
|
||||
isMediaPlaybackActive: () => playing,
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(sent, [
|
||||
{ channel: IPC_CHANNELS.event.sessionHelpOpen, payload: { commandsEnabled: playing } },
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { SessionHelpOpenPayload } from '../../types/runtime';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help';
|
||||
@@ -18,6 +19,8 @@ export async function openSessionHelpModal(deps: {
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
/** True when mpv has a video loaded; gates running commands from help rows. */
|
||||
isMediaPlaybackActive: () => boolean;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
@@ -29,8 +32,11 @@ export async function openSessionHelpModal(deps: {
|
||||
timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
sendOpen: () => {
|
||||
const payload: SessionHelpOpenPayload = {
|
||||
commandsEnabled: deps.isMediaPlaybackActive(),
|
||||
};
|
||||
return openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
@@ -40,9 +46,11 @@ export async function openSessionHelpModal(deps: {
|
||||
{
|
||||
channel: IPC_CHANNELS.event.sessionHelpOpen,
|
||||
modal: SESSION_HELP_MODAL,
|
||||
payload,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -368,7 +368,10 @@ test('cancelling a YouTube Whisper job resolves to null, and failures throw', as
|
||||
);
|
||||
});
|
||||
|
||||
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
|
||||
function youtubeFixture(
|
||||
generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube'],
|
||||
overrides: Partial<SubtitleGenerationRuntimeDeps> = {},
|
||||
) {
|
||||
const player = {
|
||||
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
|
||||
paused: false,
|
||||
@@ -385,7 +388,7 @@ function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generate
|
||||
return { error: 'success' };
|
||||
},
|
||||
};
|
||||
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
|
||||
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube, ...overrides });
|
||||
return { runtime, player, commands };
|
||||
}
|
||||
|
||||
@@ -443,3 +446,63 @@ test('switching videos cancels a YouTube job from the modal without loading its
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Windows plays YouTube through a resolved googlevideo stream, so mpv's path is not a YouTube URL.
|
||||
const STREAM_URL = 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777';
|
||||
const OTHER_STREAM_URL = 'https://rr2---sn.example.googlevideo.com/videoplayback?expire=1888888888';
|
||||
const resolveStreamSource = (mediaPath: string): string | null =>
|
||||
mediaPath === STREAM_URL
|
||||
? 'https://www.youtube.com/watch?v=abcdefghijk'
|
||||
: mediaPath === OTHER_STREAM_URL
|
||||
? 'https://www.youtube.com/watch?v=zyxwvutsrqp'
|
||||
: null;
|
||||
|
||||
test('the modal generates for a YouTube video playing through a resolved stream URL', async () => {
|
||||
let jobSignal: AbortSignal | null = null;
|
||||
const { runtime, player, commands } = youtubeFixture(
|
||||
async (input) => {
|
||||
jobSignal = input.signal;
|
||||
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
|
||||
// Reloading the same video's stream must not cancel the job.
|
||||
runtime.handleMediaPathChange(STREAM_URL);
|
||||
return '/tmp/subs/youtube-whisper.ja.srt';
|
||||
},
|
||||
{ resolveYoutubeSourceUrl: resolveStreamSource },
|
||||
);
|
||||
player.path = STREAM_URL;
|
||||
|
||||
assert.equal(
|
||||
(await runtime.getStatus()).mediaPath,
|
||||
'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
);
|
||||
assert.deepEqual(await runtime.start(), {
|
||||
ok: true,
|
||||
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
|
||||
message: 'Japanese subtitles generated and loaded.',
|
||||
});
|
||||
assert.equal((jobSignal as AbortSignal | null)?.aborted, false);
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'sub-add'),
|
||||
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
|
||||
);
|
||||
});
|
||||
|
||||
test('switching to another resolved YouTube stream cancels the modal job', async () => {
|
||||
let jobSignal: AbortSignal | null = null;
|
||||
const { runtime, player } = youtubeFixture(
|
||||
(input) =>
|
||||
new Promise((_, reject) => {
|
||||
jobSignal = input.signal;
|
||||
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||
}),
|
||||
{ resolveYoutubeSourceUrl: resolveStreamSource },
|
||||
);
|
||||
player.path = STREAM_URL;
|
||||
|
||||
const job = runtime.start();
|
||||
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
|
||||
player.path = OTHER_STREAM_URL;
|
||||
runtime.handleMediaPathChange(player.path);
|
||||
|
||||
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
|
||||
});
|
||||
@@ -51,6 +51,11 @@ export interface SubtitleGenerationRuntimeDeps {
|
||||
signal: AbortSignal;
|
||||
onProgress: (progress: SubtitleGenerationProgress) => void;
|
||||
}) => Promise<string>;
|
||||
/**
|
||||
* Maps a playing stream URL back to its YouTube page URL. Windows YouTube playback opens a
|
||||
* resolved direct stream in mpv, so mpv's path alone does not identify the video.
|
||||
*/
|
||||
resolveYoutubeSourceUrl?: (mediaPath: string) => string | null;
|
||||
}
|
||||
|
||||
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||
@@ -62,12 +67,6 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
|
||||
return typeof directory === 'string' ? path.resolve(directory, media) : null;
|
||||
}
|
||||
|
||||
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||
if (!client?.connected) return null;
|
||||
const media = await client.requestProperty('path');
|
||||
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
|
||||
}
|
||||
|
||||
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
|
||||
const loaded = await client.request([
|
||||
'sub-add',
|
||||
@@ -136,6 +135,15 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
: '',
|
||||
};
|
||||
}
|
||||
// The YouTube page behind a resolved stream URL, or the media path itself.
|
||||
function youtubeSource(mediaPath: string | null | undefined): string | null | undefined {
|
||||
return (mediaPath && deps.resolveYoutubeSourceUrl?.(mediaPath)) || mediaPath;
|
||||
}
|
||||
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||
if (!client?.connected) return null;
|
||||
const media = await client.requestProperty('path');
|
||||
return typeof media === 'string' ? toYoutubeWatchUrl(youtubeSource(media)) : null;
|
||||
}
|
||||
const report = (update: SubtitleGenerationProgress) => {
|
||||
progress = update;
|
||||
deps.onProgress(update);
|
||||
@@ -246,7 +254,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
||||
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
|
||||
handleMediaPathChange(mediaPath: string | null | undefined): void {
|
||||
if (!youtubeJobUrl) return;
|
||||
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
|
||||
if (extractYoutubeVideoId(youtubeSource(mediaPath)) !== extractYoutubeVideoId(youtubeJobUrl))
|
||||
controller?.abort();
|
||||
},
|
||||
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assertYomitanDictionaryMutationSafe,
|
||||
observeYomitanDictionaryCount,
|
||||
} from './yomitan-dictionary-integrity';
|
||||
|
||||
function withTempDir(run: (directory: string) => void): void {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-dictionary-integrity-'));
|
||||
try {
|
||||
run(directory);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('dictionary integrity observation establishes and updates a non-empty baseline', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 5), {
|
||||
safe: true,
|
||||
previousCount: null,
|
||||
});
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
|
||||
safe: true,
|
||||
previousCount: 5,
|
||||
});
|
||||
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
|
||||
const unchangedTimestamp = new Date('2000-01-01T00:00:00.000Z');
|
||||
fs.utimesSync(statePath, unchangedTimestamp, unchangedTimestamp);
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
|
||||
safe: true,
|
||||
previousCount: 3,
|
||||
});
|
||||
assert.equal(fs.statSync(statePath).mtimeMs, unchangedTimestamp.getTime());
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { lastKnownNonEmptyCount: 3 });
|
||||
assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity permits an empty profile before dictionaries are installed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
|
||||
safe: true,
|
||||
previousCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity rejects invalid counts without creating a safety record', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
for (const invalidCount of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, invalidCount), {
|
||||
safe: false,
|
||||
previousCount: null,
|
||||
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity migrates the previous count from first-run setup state', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
fs.writeFileSync(
|
||||
path.join(userDataPath, 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-08-18T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 4,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: false,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
bunInstallStatus: 'installed',
|
||||
launcherInstallStatus: 'installed',
|
||||
launcherInstallPath: '/home/tester/.local/bin/subminer',
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
|
||||
/reported zero dictionaries after previously reporting 4/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity blocks automatic mutation after a non-empty profile becomes empty', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
observeYomitanDictionaryCount(userDataPath, 6);
|
||||
|
||||
assert.throws(
|
||||
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
|
||||
/reported zero dictionaries after previously reporting 6/,
|
||||
);
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
|
||||
safe: false,
|
||||
previousCount: 6,
|
||||
message:
|
||||
'Yomitan reported zero dictionaries after previously reporting 6. SubMiner blocked automatic dictionary changes because Chromium storage may have been reset. Close SubMiner and restore or inspect the profile before changing dictionaries.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity fails closed when its state is malformed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
|
||||
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||
|
||||
assert.throws(
|
||||
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /could not verify Yomitan dictionary storage/);
|
||||
assert.equal(error.message.includes(statePath), true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeTextFileAtomicallyDurable } from '../../shared/fs-utils';
|
||||
import { getSetupStatePath, readSetupState } from '../../shared/setup-state';
|
||||
|
||||
const INTEGRITY_STATE_FILE_NAME = 'yomitan-dictionary-integrity.json';
|
||||
|
||||
type DictionaryIntegrityState = {
|
||||
lastKnownNonEmptyCount: number;
|
||||
};
|
||||
|
||||
export type DictionaryIntegrityObservation =
|
||||
| { safe: true; previousCount: number | null }
|
||||
| { safe: false; previousCount: number | null; message: string };
|
||||
|
||||
function getStatePath(userDataPath: string): string {
|
||||
return path.join(userDataPath, INTEGRITY_STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function readState(statePath: string): DictionaryIntegrityState | null {
|
||||
if (!fs.existsSync(statePath)) return null;
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(statePath, 'utf8'),
|
||||
) as Partial<DictionaryIntegrityState>;
|
||||
const lastKnownNonEmptyCount = parsed.lastKnownNonEmptyCount;
|
||||
if (
|
||||
typeof lastKnownNonEmptyCount !== 'number' ||
|
||||
!Number.isSafeInteger(lastKnownNonEmptyCount) ||
|
||||
lastKnownNonEmptyCount <= 0
|
||||
) {
|
||||
throw new Error('The dictionary integrity record has an invalid format.');
|
||||
}
|
||||
return { lastKnownNonEmptyCount };
|
||||
}
|
||||
|
||||
function writeState(statePath: string, state: DictionaryIntegrityState): void {
|
||||
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readLegacySetupCount(userDataPath: string): number | null {
|
||||
const setupState = readSetupState(getSetupStatePath(userDataPath));
|
||||
return setupState && setupState.lastSeenYomitanDictionaryCount > 0
|
||||
? setupState.lastSeenYomitanDictionaryCount
|
||||
: null;
|
||||
}
|
||||
|
||||
export function observeYomitanDictionaryCount(
|
||||
userDataPath: string,
|
||||
dictionaryCount: number,
|
||||
): DictionaryIntegrityObservation {
|
||||
if (!Number.isSafeInteger(dictionaryCount) || dictionaryCount < 0) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: null,
|
||||
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedCount = dictionaryCount;
|
||||
const statePath = getStatePath(userDataPath);
|
||||
let state: DictionaryIntegrityState | null;
|
||||
|
||||
try {
|
||||
state = readState(statePath);
|
||||
if (state === null) {
|
||||
const legacyCount = readLegacySetupCount(userDataPath);
|
||||
state = legacyCount === null ? null : { lastKnownNonEmptyCount: legacyCount };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: null,
|
||||
message: `SubMiner could not verify Yomitan dictionary storage at ${statePath}: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedCount === 0 && state !== null) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: state.lastKnownNonEmptyCount,
|
||||
message: [
|
||||
`Yomitan reported zero dictionaries after previously reporting ${state.lastKnownNonEmptyCount}.`,
|
||||
'SubMiner blocked automatic dictionary changes because Chromium storage may have been reset.',
|
||||
'Close SubMiner and restore or inspect the profile before changing dictionaries.',
|
||||
].join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedCount > 0 && normalizedCount !== state?.lastKnownNonEmptyCount) {
|
||||
try {
|
||||
writeState(statePath, { lastKnownNonEmptyCount: normalizedCount });
|
||||
} catch (error) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: state?.lastKnownNonEmptyCount ?? null,
|
||||
message: `SubMiner could not update the Yomitan dictionary integrity record: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { safe: true, previousCount: state?.lastKnownNonEmptyCount ?? null };
|
||||
}
|
||||
|
||||
export function assertYomitanDictionaryMutationSafe(
|
||||
userDataPath: string,
|
||||
dictionaryCount: number,
|
||||
): void {
|
||||
const observation = observeYomitanDictionaryCount(userDataPath, dictionaryCount);
|
||||
if (!observation.safe) {
|
||||
throw new Error(observation.message);
|
||||
}
|
||||
}
|
||||
@@ -298,3 +298,116 @@ test('prepare youtube playback accepts a fresh-start path change when the direct
|
||||
'replace',
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback swaps a queued entry for its direct stream without clearing the playlist', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||
const observedPaths = ['https://www.youtube.com/watch?v=queued2', directUrl];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||
requestCount += 1;
|
||||
return value;
|
||||
},
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued1' },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true, playing: true },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued3' },
|
||||
];
|
||||
}
|
||||
if (name === 'track-list') return [{ type: 'video', id: 1 }];
|
||||
return null;
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({
|
||||
url: directUrl,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
|
||||
timeoutMs: 1500,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands.slice(4), [
|
||||
['loadfile', directUrl, 'append'],
|
||||
['playlist-move', '3', '2'],
|
||||
['playlist-remove', '1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback replaces the playlist when the current entry is a different video', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||
let nowTick = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => 'https://www.youtube.com/watch?v=other',
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{ filename: 'https://www.youtube.com/watch?v=other', current: true, playing: true },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
now: () => {
|
||||
nowTick += 100;
|
||||
return nowTick;
|
||||
},
|
||||
});
|
||||
|
||||
await prepare({
|
||||
url: directUrl,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=picked',
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback does not touch the playlist when the playing entry is another video', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||
let nowTick = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
// mpv's path still reports the queued video, but it is mid-advance: the selected entry moved
|
||||
// on and the playing entry is another queued video.
|
||||
requestPath: async () => 'https://www.youtube.com/watch?v=queued2',
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued1', playing: true },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued3' },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
now: () => {
|
||||
nowTick += 100;
|
||||
return nowTick;
|
||||
},
|
||||
});
|
||||
|
||||
await prepare({
|
||||
url: directUrl,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'playlist-remove'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
|
||||
});
|
||||
@@ -2,6 +2,11 @@ import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type YoutubePlaybackLaunchInput = {
|
||||
url: string;
|
||||
/**
|
||||
* YouTube page URL `url` was resolved from (Windows loads a direct stream URL). Lets a playlist
|
||||
* entry for the same video be swapped in place instead of replacing the whole playlist.
|
||||
*/
|
||||
sourceUrl?: string;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
@@ -81,6 +86,52 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
/** Index of the entry mpv is actually playing, or null while it is between entries. */
|
||||
function findPlayingPlaylistEntry(playlist: unknown[]): { index: number; filename: string } | null {
|
||||
const index = playlist.findIndex(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).playing === true,
|
||||
);
|
||||
if (index < 0) return null;
|
||||
const filename = (playlist[index] as Record<string, unknown>).filename;
|
||||
return typeof filename === 'string' ? { index, filename } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps mpv's playing playlist entry for `targetUrl` while keeping the rest of the playlist, e.g.
|
||||
* a queued YouTube watch URL replaced by its resolved direct stream on Windows. Uses only
|
||||
* long-standing playlist commands (no `loadfile insert-*`, which needs mpv 0.38+).
|
||||
* Returns false unless the playing entry is confirmed to be `sourceUrl`'s video, so the caller
|
||||
* falls back to `replace` instead of removing some other queued entry.
|
||||
*/
|
||||
async function swapCurrentPlaylistEntry(
|
||||
deps: YoutubePlaybackLaunchDeps,
|
||||
targetUrl: string,
|
||||
sourceUrl: string,
|
||||
): Promise<boolean> {
|
||||
if (!deps.requestProperty) return false;
|
||||
let playlist: unknown;
|
||||
try {
|
||||
// One snapshot so the position, count, and entry identity agree with each other.
|
||||
playlist = await deps.requestProperty('playlist');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(playlist)) return false;
|
||||
const playing = findPlayingPlaylistEntry(playlist);
|
||||
if (!playing || !targetsSameYoutubeVideo(playing.filename, sourceUrl)) return false;
|
||||
|
||||
// The appended entry lands at index `count`; move it right after the playing entry, then remove
|
||||
// the playing entry so mpv advances onto it.
|
||||
const count = playlist.length;
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'append']);
|
||||
deps.sendMpvCommand(['playlist-move', String(count), String(playing.index + 1)]);
|
||||
deps.sendMpvCommand(['playlist-remove', String(playing.index)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
|
||||
sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
@@ -120,7 +171,14 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa
|
||||
// Keep polling; mpv can report the target path before tracks are ready.
|
||||
}
|
||||
} else {
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||
const sourceUrl = input.sourceUrl?.trim() ?? '';
|
||||
const swapped =
|
||||
sourceUrl !== '' &&
|
||||
targetsSameYoutubeVideo(previousPath, sourceUrl) &&
|
||||
(await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl));
|
||||
if (!swapped) {
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = now() + timeoutMs;
|
||||
|
||||
@@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
calls.push(`wait-connected:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||
calls.push(`prepare:${url}`);
|
||||
prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => {
|
||||
calls.push(`prepare:${url}:${sourceUrl}`);
|
||||
return true;
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url }) => {
|
||||
@@ -143,6 +143,51 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
});
|
||||
|
||||
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
||||
// The page URL rides along so prepare can swap a queued entry for the direct stream in place.
|
||||
assert.ok(calls.includes('prepare:https://example.com/direct:https://youtu.be/demo'));
|
||||
});
|
||||
|
||||
test('youtube playback runtime maps resolved windows streams back to their page urls', async () => {
|
||||
const streamFor = (url: string) =>
|
||||
`https://rr1---sn.example.googlevideo.com/videoplayback?src=${encodeURIComponent(url)}`;
|
||||
const firstUrl = 'https://www.youtube.com/watch?v=abcdefghijk';
|
||||
const secondUrl = 'https://www.youtube.com/watch?v=bcdefghijkl';
|
||||
const runtime = createYoutubePlaybackRuntime({
|
||||
platform: 'win32',
|
||||
directPlaybackFormat: 'b',
|
||||
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||
autoLaunchTimeoutMs: 2_000,
|
||||
connectTimeoutMs: 1_000,
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getMpvConnected: () => true,
|
||||
invalidatePendingAutoplayReadyFallbacks: () => {},
|
||||
setAppOwnedFlowInFlight: () => {},
|
||||
ensureYoutubePlaybackRuntimeReady: async () => {},
|
||||
resolveYoutubePlaybackUrl: async (url) => streamFor(url),
|
||||
launchWindowsMpv: async () => ({ ok: false }),
|
||||
waitForYoutubeMpvConnected: async () => true,
|
||||
// The second video never loads, so the first stream keeps playing.
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => url === streamFor(firstUrl),
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
schedule: () => 1 as never,
|
||||
clearScheduled: () => {},
|
||||
});
|
||||
|
||||
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), null);
|
||||
await runtime.runYoutubePlaybackFlow({ url: firstUrl, source: 'second-instance' });
|
||||
await runtime
|
||||
.runYoutubePlaybackFlow({ url: secondUrl, source: 'second-instance' })
|
||||
.catch(() => {});
|
||||
|
||||
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), firstUrl);
|
||||
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(secondUrl)), secondUrl);
|
||||
assert.equal(
|
||||
runtime.getYoutubeSourceUrlForStream('https://rr2---sn.example.googlevideo.com/other'),
|
||||
null,
|
||||
);
|
||||
assert.equal(runtime.getYoutubeSourceUrlForStream('/video/episode.mkv'), null);
|
||||
});
|
||||
|
||||
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
||||
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string; sourceUrl: string }) => Promise<boolean>;
|
||||
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
@@ -35,6 +35,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
let quitOnDisconnectArmed = false;
|
||||
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let playbackFlowGeneration = 0;
|
||||
// Windows mpv plays resolved stream URLs, so remember which YouTube page each came from. Kept for
|
||||
// every resolved stream: a later failed load must not orphan the stream still playing, and mpv
|
||||
// can return to an earlier swapped-in playlist entry.
|
||||
const directPlaybackSources = new Map<string, string>();
|
||||
|
||||
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
|
||||
if (quitOnDisconnectArmTimer) {
|
||||
@@ -65,6 +69,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
request.url,
|
||||
deps.directPlaybackFormat,
|
||||
);
|
||||
directPlaybackSources.set(playbackUrl, request.url);
|
||||
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
|
||||
} catch (error) {
|
||||
deps.logWarn(
|
||||
@@ -122,7 +127,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
|
||||
const mediaReady = await deps.prepareYoutubePlaybackInMpv({
|
||||
url: playbackUrl,
|
||||
sourceUrl: request.url,
|
||||
});
|
||||
if (!mediaReady) {
|
||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||
}
|
||||
@@ -156,5 +164,8 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
clearYoutubePlayQuitOnDisconnectArmTimer,
|
||||
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
|
||||
runYoutubePlaybackFlow,
|
||||
/** YouTube page URL for a stream URL this runtime resolved for Windows playback, else null. */
|
||||
getYoutubeSourceUrlForStream: (mediaPath: string | null | undefined): string | null =>
|
||||
directPlaybackSources.get(mediaPath?.trim() ?? '') ?? null,
|
||||
};
|
||||
}
|
||||
+34
-1
@@ -61,6 +61,7 @@ import type {
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
SessionHelpOpenPayload,
|
||||
SessionNumericSelectionStartPayload,
|
||||
SubtitleMiningContext,
|
||||
YoutubePickerOpenPayload,
|
||||
@@ -71,6 +72,7 @@ import type {
|
||||
ChangelogSnapshot,
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewPosition,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
MediaTimingReviewFrameRequest,
|
||||
@@ -176,7 +178,15 @@ function createLatestValueIpcListenerWithPayload<T>(
|
||||
}
|
||||
|
||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpenPayload>(
|
||||
IPC_CHANNELS.event.sessionHelpOpen,
|
||||
(payload) => ({
|
||||
commandsEnabled:
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
(payload as Partial<SessionHelpOpenPayload>).commandsEnabled === true,
|
||||
}),
|
||||
);
|
||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||
@@ -511,6 +521,26 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
||||
onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent,
|
||||
onMediaTimingReviewPreviewPosition: (
|
||||
callback: (position: MediaTimingReviewPreviewPosition) => void,
|
||||
): void => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.event.mediaTimingReviewPreviewPosition,
|
||||
(_event, payload: unknown) => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('reviewId' in payload) ||
|
||||
typeof payload.reviewId !== 'string' ||
|
||||
!('time' in payload) ||
|
||||
typeof payload.time !== 'number' ||
|
||||
!Number.isFinite(payload.time)
|
||||
)
|
||||
return;
|
||||
callback({ reviewId: payload.reviewId, time: payload.time });
|
||||
},
|
||||
);
|
||||
},
|
||||
previewMediaTimingReview: (
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
): Promise<MediaTimingReviewActionResult> =>
|
||||
@@ -594,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
@@ -42,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;
|
||||
@@ -57,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({
|
||||
@@ -88,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');
|
||||
@@ -111,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,
|
||||
@@ -257,7 +296,10 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
if (binding.actionType === 'mpv-command') {
|
||||
dispatchConfiguredMpvCommand(binding.command);
|
||||
void dispatchConfiguredMpvCommand(binding.command, {
|
||||
getPlaybackPaused: options.getPlaybackPaused,
|
||||
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -566,31 +608,6 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function isSubtitleSeekCommand(
|
||||
command: (string | number)[] | undefined,
|
||||
): command is [string, number] {
|
||||
return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number';
|
||||
}
|
||||
|
||||
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {
|
||||
if (!isSubtitleSeekCommand(command)) {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
return;
|
||||
}
|
||||
|
||||
void options
|
||||
.getPlaybackPaused()
|
||||
.then((paused) => {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
if (paused !== false) {
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
});
|
||||
}
|
||||
|
||||
type ScanModifierState = {
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
@@ -1311,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) => {
|
||||
@@ -1340,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)) {
|
||||
@@ -1356,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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MediaTimingReviewContextLine,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewPosition,
|
||||
} from '../../types/anki';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
@@ -20,8 +21,8 @@ const LINE_REVEAL_MARGIN_SECONDS = 1;
|
||||
const SPEECH_LEVEL_THRESHOLD = 0.3;
|
||||
const SPEECH_TAIL_SECONDS = 0.15;
|
||||
const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
|
||||
/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */
|
||||
const PREVIEW_END_GRACE_MS = 2_500;
|
||||
/** Allow buffering and output-device delays without cutting off an advancing preview. */
|
||||
const PREVIEW_STALL_TIMEOUT_MS = 15_000;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
@@ -303,30 +304,49 @@ export function createMediaTimingReviewModal(
|
||||
previewTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the play button label plus the playhead sweep that mirrors the hidden audio player.
|
||||
* mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for
|
||||
* output latency such as Bluetooth headphones; the timer only covers a player that never does.
|
||||
*/
|
||||
function setPreviewPlaying(playing: boolean): void {
|
||||
function armPreviewTimeout(): void {
|
||||
clearPreviewTimer();
|
||||
previewTimer = setTimeout(() => {
|
||||
stopPreview();
|
||||
setStatus('Audio preview stopped responding. Try playing the selection again.', true);
|
||||
}, PREVIEW_STALL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Playback position comes from mpv, so stalls and output latency cannot outrun the cursor. */
|
||||
function setPreviewPlaying(playing: boolean, finished = false): void {
|
||||
previewPlaying = playing;
|
||||
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
||||
clearPreviewTimer();
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
track.classList.remove('is-previewing');
|
||||
track.classList.toggle('is-previewing', playing || finished);
|
||||
if (finished) track.style.setProperty('--playhead-position', 'var(--selection-end)');
|
||||
if (!playing) return;
|
||||
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
|
||||
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
|
||||
void track.offsetWidth;
|
||||
track.classList.add('is-previewing');
|
||||
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
|
||||
track.style.setProperty('--playhead-position', 'var(--selection-start)');
|
||||
armPreviewTimeout();
|
||||
}
|
||||
|
||||
function handlePreviewPosition(position: MediaTimingReviewPreviewPosition): void {
|
||||
if (
|
||||
!payload ||
|
||||
payload.reviewId !== position.reviewId ||
|
||||
!previewPlaying ||
|
||||
!Number.isFinite(position.time)
|
||||
)
|
||||
return;
|
||||
const time = clamp(position.time, selectionStart, selectionEnd);
|
||||
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--playhead-position',
|
||||
`${clamp(((time - timelineStart) / span) * 100, 0, 100)}%`,
|
||||
);
|
||||
armPreviewTimeout();
|
||||
}
|
||||
|
||||
/** The hidden player reached the end of the clip and paused itself. */
|
||||
function handlePreviewEnded(reviewId: string): void {
|
||||
if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
|
||||
setPreviewPlaying(false);
|
||||
setPreviewPlaying(false, true);
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
@@ -1026,6 +1046,7 @@ export function createMediaTimingReviewModal(
|
||||
return {
|
||||
openMediaTimingReviewModal,
|
||||
handlePreviewEnded,
|
||||
handlePreviewPosition,
|
||||
requestCancel,
|
||||
handleMediaTimingReviewKeydown,
|
||||
wireDomEvents,
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CompiledSessionBinding } from '../../types.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { buildSessionHelpSections, createSessionHelpModal } from './session-help.js';
|
||||
|
||||
/** Just enough DOM for the help modal to render rows and route row events. */
|
||||
class FakeElement {
|
||||
children: FakeElement[] = [];
|
||||
parent: FakeElement | null = null;
|
||||
dataset: Record<string, string> = {};
|
||||
style: Record<string, string> = {};
|
||||
textContent = '';
|
||||
value = '';
|
||||
tabIndex = 0;
|
||||
type = '';
|
||||
private classes = new Set<string>();
|
||||
private listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
|
||||
classList = {
|
||||
add: (...tokens: string[]) => tokens.forEach((token) => this.classes.add(token)),
|
||||
remove: (...tokens: string[]) => tokens.forEach((token) => this.classes.delete(token)),
|
||||
toggle: (token: string, force?: boolean) => {
|
||||
const on = force ?? !this.classes.has(token);
|
||||
if (on) this.classes.add(token);
|
||||
else this.classes.delete(token);
|
||||
return on;
|
||||
},
|
||||
contains: (token: string) => this.classes.has(token),
|
||||
};
|
||||
|
||||
set className(value: string) {
|
||||
this.classes = new Set(value.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
get className(): string {
|
||||
return [...this.classes].join(' ');
|
||||
}
|
||||
|
||||
set innerHTML(_value: string) {
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
appendChild(child: FakeElement): FakeElement {
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child: FakeElement, ref: FakeElement): FakeElement {
|
||||
child.parent = this;
|
||||
const index = this.children.indexOf(ref);
|
||||
this.children.splice(index < 0 ? this.children.length : index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown>): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
(globalThis.document as unknown as { activeElement: unknown }).activeElement = this;
|
||||
}
|
||||
|
||||
contains(node: unknown): boolean {
|
||||
for (let current = node as FakeElement | null; current; current = current.parent) {
|
||||
if (current === this) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
for (let current: FakeElement | null = this; current; current = current.parent) {
|
||||
if (current.classList.contains(selector.slice(1))) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): FakeElement[] {
|
||||
const matches: FakeElement[] = [];
|
||||
const visit = (node: FakeElement) => {
|
||||
for (const child of node.children) {
|
||||
if (child.classList.contains(selector.slice(1))) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
setAttribute(): void {}
|
||||
removeEventListener(): void {}
|
||||
select(): void {}
|
||||
scrollIntoView(): void {}
|
||||
getClientRects(): unknown[] {
|
||||
return [{}];
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_BINDINGS: CompiledSessionBinding[] = [
|
||||
{
|
||||
sourcePath: 'stats.toggleKey',
|
||||
originalKey: 'Backquote',
|
||||
key: { code: 'Backquote', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleStatsOverlay',
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||
originalKey: 'KeyO',
|
||||
key: { code: 'KeyO', modifiers: ['alt'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleVisibleOverlay',
|
||||
},
|
||||
];
|
||||
|
||||
function withFakeDom(
|
||||
run: (harness: ReturnType<typeof createHarness>) => Promise<void>,
|
||||
options: HarnessOptions = {},
|
||||
) {
|
||||
return async () => {
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
const saved = ['window', 'document', 'HTMLElement', 'Element'].map((key) => [
|
||||
key,
|
||||
globals[key],
|
||||
]);
|
||||
try {
|
||||
await run(createHarness(options));
|
||||
} finally {
|
||||
for (const [key, value] of saved) {
|
||||
Object.defineProperty(globalThis, key as string, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type HarnessOptions = { failActions?: boolean; bindings?: CompiledSessionBinding[] };
|
||||
|
||||
function createHarness(options: HarnessOptions) {
|
||||
const mpvCommands: (string | number)[][] = [];
|
||||
const sessionActions: string[] = [];
|
||||
/** IPC in send order; main destroys the modal window on `closed`, dropping anything after it. */
|
||||
const ipcLog: string[] = [];
|
||||
const define = (key: string, value: unknown) =>
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
|
||||
define('HTMLElement', FakeElement);
|
||||
define('Element', FakeElement);
|
||||
define('document', {
|
||||
activeElement: null,
|
||||
createElement: () => new FakeElement(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
define('window', {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
notifyOverlayModalClosed: () => ipcLog.push('closed'),
|
||||
getSessionBindings: async () => options.bindings ?? SESSION_BINDINGS,
|
||||
getSubtitleStyle: async () => ({}),
|
||||
getMarkWatchedKey: async () => null,
|
||||
getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }),
|
||||
getRuntimeOptions: async () => [],
|
||||
getPlaybackPaused: async () => true,
|
||||
sendMpvCommand: (command: (string | number)[]) => {
|
||||
mpvCommands.push(command);
|
||||
ipcLog.push(`mpv:${command.join(' ')}`);
|
||||
},
|
||||
dispatchSessionAction: async (actionId: string) => {
|
||||
sessionActions.push(actionId);
|
||||
ipcLog.push(`action:${actionId}`);
|
||||
if (options.failActions) throw new Error('boom');
|
||||
},
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
});
|
||||
|
||||
const dom = {
|
||||
overlay: new FakeElement(),
|
||||
sessionHelpModal: new FakeElement(),
|
||||
sessionHelpFilter: new FakeElement(),
|
||||
sessionHelpContent: new FakeElement(),
|
||||
sessionHelpClose: new FakeElement(),
|
||||
sessionHelpShortcut: new FakeElement(),
|
||||
sessionHelpWarning: new FakeElement(),
|
||||
sessionHelpStatus: new FakeElement(),
|
||||
};
|
||||
const state = createRendererState();
|
||||
const modal = createSessionHelpModal(
|
||||
{
|
||||
state,
|
||||
dom,
|
||||
platform: {
|
||||
overlayLayer: 'modal',
|
||||
isModalLayer: true,
|
||||
isLinuxPlatform: true,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
modal.wireDomEvents();
|
||||
|
||||
async function open(commandsEnabled: boolean): Promise<void> {
|
||||
modal.openSessionHelpModal(
|
||||
{ bindingKey: 'KeyH', fallbackUsed: false, fallbackUnavailable: false },
|
||||
{ commandsEnabled },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** Keydown targets the focused element, as in the browser; returns whether default was prevented. */
|
||||
function pressEnter(target = (document as unknown as { activeElement: unknown }).activeElement) {
|
||||
let prevented = false;
|
||||
modal.handleSessionHelpKeydown({
|
||||
key: 'Enter',
|
||||
target,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
} as KeyboardEvent);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item');
|
||||
return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions, ipcLog };
|
||||
}
|
||||
|
||||
test('session help rows carry runnable commands except numeric and self-opening actions', () => {
|
||||
const sections = buildSessionHelpSections({
|
||||
sessionBindings: [
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.copySubtitleMultiple',
|
||||
originalKey: 'Shift+KeyC',
|
||||
key: { code: 'KeyC', modifiers: ['shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'copySubtitleMultiple',
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.openSessionHelp',
|
||||
originalKey: 'Slash',
|
||||
key: { code: 'Slash', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
],
|
||||
markWatchedKey: 'KeyW',
|
||||
subtitleStyle: {},
|
||||
});
|
||||
const rows = sections.flatMap((section) => section.rows);
|
||||
const commandFor = (action: string) => rows.find((row) => row.action === action)?.command;
|
||||
|
||||
assert.deepEqual(commandFor('Toggle playback'), {
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
});
|
||||
assert.deepEqual(commandFor('Mark video watched'), {
|
||||
actionType: 'session-action',
|
||||
actionId: 'markWatched',
|
||||
});
|
||||
assert.equal(commandFor('Copy subtitle (multi)'), undefined);
|
||||
assert.equal(commandFor('Open session help'), undefined);
|
||||
assert.equal(commandFor('Toggle primary subtitle bar visibility'), undefined);
|
||||
});
|
||||
|
||||
test(
|
||||
'session help runs the selected command on Enter during playback and closes',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
assert.ok(harness.rows()[0]?.classList.contains('session-help-item-runnable'));
|
||||
|
||||
harness.pressEnter();
|
||||
|
||||
assert.deepEqual(harness.ipcLog, ['action:toggleStatsOverlay', 'closed']);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, false);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help sends a paused subtitle seek before closing',
|
||||
withFakeDom(
|
||||
async (harness) => {
|
||||
await harness.open(true);
|
||||
// Playback rows live on another tab; a filter searches every tab.
|
||||
harness.dom.sessionHelpFilter.value = 'next subtitle';
|
||||
harness.dom.sessionHelpFilter.dispatch('input', {});
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('dblclick', { target: harness.rows()[0] });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.ipcLog, ['mpv:sub-seek 1', 'mpv:set_property pause yes', 'closed']);
|
||||
},
|
||||
{
|
||||
bindings: [
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Shift+KeyL',
|
||||
key: { code: 'KeyL', modifiers: ['shift'] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['sub-seek', 1],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help ignores Enter when no video is playing',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(false);
|
||||
assert.equal(harness.rows()[0]?.classList.contains('session-help-item-runnable'), false);
|
||||
|
||||
harness.pressEnter();
|
||||
|
||||
assert.deepEqual(harness.sessionActions, []);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help hover picks the Enter target and double-click runs the row',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
const [statsRow, overlayRow, fixedRow] = harness.rows();
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('mousemove', { target: overlayRow });
|
||||
assert.equal(harness.state.sessionHelpSelectedIndex, 1);
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('dblclick', { target: fixedRow });
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
|
||||
harness.dom.sessionHelpContent.dispatch('dblclick', { target: statsRow?.children[0] });
|
||||
assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, false);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help reports a failed session action on the mpv OSD',
|
||||
withFakeDom(
|
||||
async (harness) => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
await harness.open(true);
|
||||
harness.pressEnter();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
|
||||
assert.deepEqual(harness.mpvCommands, [['show-text', 'Command failed to run', '3000']]);
|
||||
},
|
||||
{ failActions: true },
|
||||
),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help does not run a command from Enter pressed in the filter',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
// The filter's own listener already moved focus to a row before this bubbles up.
|
||||
harness.rows()[0]?.focus();
|
||||
|
||||
harness.pressEnter(harness.dom.sessionHelpFilter);
|
||||
|
||||
assert.deepEqual(harness.sessionActions, []);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
'session help leaves Enter on the close button to its native activation',
|
||||
withFakeDom(async (harness) => {
|
||||
await harness.open(true);
|
||||
|
||||
const prevented = harness.pressEnter(harness.dom.sessionHelpClose);
|
||||
|
||||
assert.equal(prevented, false);
|
||||
assert.deepEqual(harness.sessionActions, []);
|
||||
assert.equal(harness.state.sessionHelpModalOpen, true);
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
|
||||
|
||||
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
|
||||
function createShortcutRow(
|
||||
row: SessionHelpItem,
|
||||
globalIndex: number,
|
||||
commandsEnabled: boolean,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'session-help-item';
|
||||
@@ -28,6 +32,14 @@ function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButto
|
||||
right.insertBefore(dot, action);
|
||||
}
|
||||
|
||||
if (commandsEnabled && row.command) {
|
||||
button.classList.add('session-help-item-runnable');
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'session-help-run-hint';
|
||||
hint.textContent = 'Run ↵';
|
||||
right.appendChild(hint);
|
||||
}
|
||||
|
||||
button.appendChild(left);
|
||||
button.appendChild(right);
|
||||
return button;
|
||||
@@ -54,6 +66,7 @@ export function createSessionHelpSectionNode(
|
||||
section: SessionHelpSection,
|
||||
sectionIndex: number,
|
||||
globalIndexMap: number[],
|
||||
commandsEnabled: boolean,
|
||||
): HTMLElement {
|
||||
const sectionNode = document.createElement('section');
|
||||
sectionNode.className = 'session-help-section';
|
||||
@@ -69,7 +82,7 @@ export function createSessionHelpSectionNode(
|
||||
|
||||
section.rows.forEach((row, rowIndex) => {
|
||||
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
||||
const button = createShortcutRow(row, globalIndex);
|
||||
const button = createShortcutRow(row, globalIndex, commandsEnabled);
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
CompiledMpvCommandBinding,
|
||||
CompiledSessionActionBinding,
|
||||
CompiledSessionBinding,
|
||||
SessionActionId,
|
||||
SessionKeyModifier,
|
||||
@@ -7,10 +9,17 @@ import type {
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
||||
import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors';
|
||||
|
||||
/** What running a help row does; compiled session bindings satisfy this shape directly. */
|
||||
export type SessionHelpCommand =
|
||||
| Pick<CompiledMpvCommandBinding, 'actionType' | 'command'>
|
||||
| Pick<CompiledSessionActionBinding, 'actionType' | 'actionId' | 'payload'>;
|
||||
|
||||
export type SessionHelpItem = {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
color?: string;
|
||||
/** Set when the help modal can run this row during playback. */
|
||||
command?: SessionHelpCommand;
|
||||
};
|
||||
|
||||
export type SessionHelpSection = {
|
||||
@@ -62,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 {
|
||||
@@ -287,6 +300,21 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric-count actions wait for a follow-up digit in the overlay, and reopening help is a no-op.
|
||||
const HELP_UNRUNNABLE_ACTIONS: ReadonlySet<SessionActionId> = new Set([
|
||||
'copySubtitleMultiple',
|
||||
'mineSentenceMultiple',
|
||||
'openSessionHelp',
|
||||
]);
|
||||
|
||||
function toSessionHelpCommand(binding: CompiledSessionBinding): SessionHelpCommand | undefined {
|
||||
if (binding.actionType === 'mpv-command') {
|
||||
return { actionType: 'mpv-command', command: binding.command };
|
||||
}
|
||||
if (HELP_UNRUNNABLE_ACTIONS.has(binding.actionId)) return undefined;
|
||||
return { actionType: 'session-action', actionId: binding.actionId, payload: binding.payload };
|
||||
}
|
||||
|
||||
function buildSessionBindingSections(
|
||||
sessionBindings: CompiledSessionBinding[],
|
||||
): SessionHelpSection[] {
|
||||
@@ -294,12 +322,14 @@ function buildSessionBindingSections(
|
||||
|
||||
for (const binding of sessionBindings) {
|
||||
const section = sectionForSessionBinding(binding);
|
||||
const command = toSessionHelpCommand(binding);
|
||||
const row: SessionHelpItem = {
|
||||
shortcut: formatSessionKeySpec(binding.key),
|
||||
action:
|
||||
binding.actionType === 'mpv-command'
|
||||
? describeCommand(binding.command)
|
||||
: describeSessionAction(binding.actionId, binding.payload),
|
||||
...(command ? { command } : {}),
|
||||
};
|
||||
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
||||
}
|
||||
@@ -337,6 +367,7 @@ function buildConfiguredOverlaySections(input: {
|
||||
statsRows.push({
|
||||
shortcut: formatKeybinding(input.markWatchedKey),
|
||||
action: 'Mark video watched',
|
||||
command: { actionType: 'session-action', actionId: 'markWatched' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,6 +376,7 @@ function buildConfiguredOverlaySections(input: {
|
||||
overlayRows.push({
|
||||
shortcut: formatKeybinding(input.subtitleSidebarToggleKey),
|
||||
action: 'Toggle subtitle sidebar',
|
||||
command: { actionType: 'session-action', actionId: 'toggleSubtitleSidebar' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import type { SessionHelpOpenPayload } from '../../types/runtime';
|
||||
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
|
||||
import {
|
||||
buildSessionHelpSections,
|
||||
type SessionHelpItem,
|
||||
type SessionHelpSection,
|
||||
type SessionHelpTabId,
|
||||
} from './session-help-sections';
|
||||
import { createSessionHelpSectionNode } from './session-help-render';
|
||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
|
||||
|
||||
export {
|
||||
buildSessionHelpSections,
|
||||
@@ -70,6 +73,9 @@ export function createSessionHelpModal(
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let activeTabId: SessionHelpTabId = 'essentials';
|
||||
// Rows backed by a command run on Enter / double-click, but only while a video is playing.
|
||||
let commandsEnabled = false;
|
||||
let visibleRows: SessionHelpItem[] = [];
|
||||
|
||||
function getItems(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
@@ -77,7 +83,8 @@ export function createSessionHelpModal(
|
||||
) as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function setSelected(index: number): void {
|
||||
/** Pointer selection skips scrolling and leaves filter typing focus alone. */
|
||||
function setSelected(index: number, fromPointer = false): void {
|
||||
const items = getItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
@@ -91,6 +98,10 @@ export function createSessionHelpModal(
|
||||
});
|
||||
const activeItem = items[next];
|
||||
if (!activeItem) return;
|
||||
if (fromPointer) {
|
||||
if (!isFilterInputFocused()) activeItem.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
activeItem.focus({ preventScroll: true });
|
||||
activeItem.scrollIntoView({
|
||||
block: 'nearest',
|
||||
@@ -117,6 +128,7 @@ export function createSessionHelpModal(
|
||||
|
||||
function applyFilterAndRender(): void {
|
||||
const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue);
|
||||
visibleRows = sections.flatMap((section) => section.rows);
|
||||
const indexOffsets: number[] = [];
|
||||
let running = 0;
|
||||
for (const section of sections) {
|
||||
@@ -134,7 +146,12 @@ export function createSessionHelpModal(
|
||||
);
|
||||
}
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets);
|
||||
const sectionNode = createSessionHelpSectionNode(
|
||||
section,
|
||||
sectionIndex,
|
||||
indexOffsets,
|
||||
commandsEnabled,
|
||||
);
|
||||
ctx.dom.sessionHelpContent.appendChild(sectionNode);
|
||||
});
|
||||
|
||||
@@ -156,6 +173,7 @@ export function createSessionHelpModal(
|
||||
|
||||
function showRenderError(message: string): void {
|
||||
helpSections = [];
|
||||
visibleRows = [];
|
||||
helpFilterValue = '';
|
||||
activeTabId = 'essentials';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
@@ -199,12 +217,17 @@ export function createSessionHelpModal(
|
||||
}
|
||||
}
|
||||
|
||||
function openSessionHelpModal(opening: SessionHelpBindingInfo): void {
|
||||
function openSessionHelpModal(
|
||||
opening: SessionHelpBindingInfo,
|
||||
payload: SessionHelpOpenPayload = { commandsEnabled: false },
|
||||
): void {
|
||||
openBinding = opening;
|
||||
commandsEnabled = payload.commandsEnabled;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
ctx.state.sessionHelpModalOpen = true;
|
||||
helpSections = [];
|
||||
visibleRows = [];
|
||||
helpFilterValue = '';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
@@ -238,8 +261,9 @@ export function createSessionHelpModal(
|
||||
void render().then((dataLoaded) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (dataLoaded) {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
||||
ctx.dom.sessionHelpStatus.textContent = commandsEnabled
|
||||
? 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Enter or double-click runs a command. Esc closes.'
|
||||
: 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
|
||||
} else {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Session help data is unavailable right now. Press Esc to close.';
|
||||
@@ -284,10 +308,47 @@ export function createSessionHelpModal(
|
||||
window.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the command before closing help. On Linux and Windows, main destroys the
|
||||
* dedicated modal window once its last modal closes, so IPC sent after the close
|
||||
* is dropped. Main still handles the command first, so a command that opens another
|
||||
* modal keeps the modal window alive.
|
||||
*/
|
||||
function runSelectedCommand(): void {
|
||||
if (!commandsEnabled) return;
|
||||
const command = visibleRows[ctx.state.sessionHelpSelectedIndex]?.command;
|
||||
if (!command) return;
|
||||
|
||||
if (command.actionType === 'mpv-command') {
|
||||
void dispatchConfiguredMpvCommand(command.command, {
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
sendMpvCommand: (mpvCommand) => window.electronAPI.sendMpvCommand(mpvCommand),
|
||||
}).finally(closeSessionHelpModal);
|
||||
return;
|
||||
}
|
||||
const dispatched = window.electronAPI.dispatchSessionAction(command.actionId, command.payload);
|
||||
closeSessionHelpModal();
|
||||
// Help is closed by the time this settles, so surface failures on the mpv OSD.
|
||||
void dispatched.catch((error: unknown) => {
|
||||
console.error(`Session help could not run ${command.actionId}`, error);
|
||||
window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']);
|
||||
});
|
||||
}
|
||||
|
||||
function getRowIndex(target: EventTarget | null): number | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const row = target.closest('.session-help-item') as HTMLElement | null;
|
||||
if (!row) return null;
|
||||
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
|
||||
return Number.isFinite(index) ? index : null;
|
||||
}
|
||||
|
||||
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.sessionHelpModalOpen) return false;
|
||||
|
||||
if (isFilterInputFocused()) {
|
||||
// The filter's own Enter listener moves focus to the list before this bubbled
|
||||
// handler runs, so check the event target too or filter Enter would run a command.
|
||||
if (isFilterInputFocused() || e.target === ctx.dom.sessionHelpFilter) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (!helpFilterValue) {
|
||||
@@ -333,6 +394,13 @@ export function createSessionHelpModal(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only rows run commands; Enter on the close or tab buttons keeps its native activation.
|
||||
if (e.key === 'Enter' && getRowIndex(e.target) !== null) {
|
||||
e.preventDefault();
|
||||
runSelectedCommand();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -350,13 +418,22 @@ export function createSessionHelpModal(
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const row = target.closest('.session-help-item') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
|
||||
if (!Number.isFinite(index)) return;
|
||||
const index = getRowIndex(event.target);
|
||||
if (index !== null) setSelected(index);
|
||||
});
|
||||
|
||||
// Hovering a row makes it the Enter target.
|
||||
ctx.dom.sessionHelpContent.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
const index = getRowIndex(event.target);
|
||||
if (index === null || index === ctx.state.sessionHelpSelectedIndex) return;
|
||||
setSelected(index, true);
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpContent.addEventListener('dblclick', (event: MouseEvent) => {
|
||||
const index = getRowIndex(event.target);
|
||||
if (index === null) return;
|
||||
setSelected(index);
|
||||
runSelectedCommand();
|
||||
});
|
||||
|
||||
ctx.dom.sessionHelpClose.addEventListener('click', () => {
|
||||
|
||||
@@ -316,7 +316,13 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
// Route through main so it can gate help commands on mpv playback state;
|
||||
// onOpenSessionHelp re-resolves the chord hint when the open event arrives.
|
||||
openSessionHelpModal: () => {
|
||||
void window.electronAPI.dispatchSessionAction('openSessionHelp').catch((error: unknown) => {
|
||||
console.error('Could not open session help', error);
|
||||
});
|
||||
},
|
||||
openControllerSelectModal: () => {
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
@@ -572,9 +578,9 @@ function registerModalOpenHandlers(): void {
|
||||
await characterDictionaryModal.openCharacterDictionaryManagerModal();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenSessionHelp(() => {
|
||||
window.electronAPI.onOpenSessionHelp((payload) => {
|
||||
runGuarded('session-help:open', () => {
|
||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo(), payload);
|
||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||
});
|
||||
});
|
||||
@@ -625,6 +631,9 @@ function registerModalOpenHandlers(): void {
|
||||
mediaTimingReviewModal.handlePreviewEnded(reviewId);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onMediaTimingReviewPreviewPosition((position) => {
|
||||
mediaTimingReviewModal.handlePreviewPosition(position);
|
||||
});
|
||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||
runGuardedAsync('playlist-browser:open', async () => {
|
||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||
@@ -770,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();
|
||||
|
||||
+19
-15
@@ -1687,7 +1687,7 @@ body:focus-visible,
|
||||
--selection-end: 80%;
|
||||
--original-start: 25%;
|
||||
--original-end: 75%;
|
||||
--playhead-duration: 1s;
|
||||
--playhead-position: var(--selection-start);
|
||||
|
||||
position: relative;
|
||||
height: 52px;
|
||||
@@ -1803,10 +1803,10 @@ body:focus-visible,
|
||||
|
||||
.media-timing-review-playhead {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
z-index: 7;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--selection-start);
|
||||
left: clamp(0px, var(--playhead-position), calc(100% - 2px));
|
||||
width: 2px;
|
||||
background: var(--ctp-yellow);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
|
||||
@@ -1816,16 +1816,6 @@ body:focus-visible,
|
||||
|
||||
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||
opacity: 1;
|
||||
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
|
||||
}
|
||||
|
||||
@keyframes media-timing-review-playhead {
|
||||
from {
|
||||
left: var(--selection-start);
|
||||
}
|
||||
to {
|
||||
left: var(--selection-end);
|
||||
}
|
||||
}
|
||||
|
||||
/* A thin line centered on the clip edge, so the line itself is the trim boundary. */
|
||||
@@ -2128,8 +2118,7 @@ body:focus-visible,
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.media-timing-review-track.is-loading::after,
|
||||
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||
.media-timing-review-track.is-loading::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -4136,6 +4125,21 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Runnable rows reveal their hint only on the row Enter would run. */
|
||||
.session-help-run-hint {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-blue);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.session-help-item-runnable.active .session-help-run-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-help-color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { dispatchConfiguredMpvCommand } from './mpv-command-dispatch';
|
||||
|
||||
async function dispatch(command: (string | number)[], paused: boolean | null | Error) {
|
||||
const sent: (string | number)[][] = [];
|
||||
dispatchConfiguredMpvCommand(command, {
|
||||
getPlaybackPaused: async () => {
|
||||
if (paused instanceof Error) throw paused;
|
||||
return paused;
|
||||
},
|
||||
sendMpvCommand: (mpvCommand) => sent.push(mpvCommand),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return sent;
|
||||
}
|
||||
|
||||
test('subtitle seeks keep paused or unknown playback paused', async () => {
|
||||
const repaused = [
|
||||
['sub-seek', 1],
|
||||
['set_property', 'pause', 'yes'],
|
||||
];
|
||||
assert.deepEqual(await dispatch(['sub-seek', 1], true), repaused);
|
||||
assert.deepEqual(await dispatch(['sub-seek', 1], null), repaused);
|
||||
});
|
||||
|
||||
test('subtitle seeks leave running playback alone', async () => {
|
||||
assert.deepEqual(await dispatch(['sub-seek', -1], false), [['sub-seek', -1]]);
|
||||
assert.deepEqual(await dispatch(['sub-seek', -1], new Error('ipc down')), [['sub-seek', -1]]);
|
||||
});
|
||||
|
||||
test('other mpv commands are sent as-is', async () => {
|
||||
assert.deepEqual(await dispatch(['cycle', 'pause'], true), [['cycle', 'pause']]);
|
||||
});
|
||||
|
||||
test('a failed re-pause does not resend the subtitle seek', async () => {
|
||||
const sent: (string | number)[][] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
dispatchConfiguredMpvCommand(['sub-seek', 1], {
|
||||
getPlaybackPaused: async () => true,
|
||||
sendMpvCommand: (command) => {
|
||||
sent.push(command);
|
||||
if (command[0] === 'set_property') throw new Error('ipc closed');
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
['sub-seek', 1],
|
||||
['set_property', 'pause', 'yes'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
type MpvCommand = (string | number)[];
|
||||
|
||||
function isSubtitleSeekCommand(command: MpvCommand): command is [string, number] {
|
||||
return command[0] === 'sub-seek' && typeof command[1] === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a configured mpv command. Subtitle seeks re-pause afterwards unless playback
|
||||
* is known to be running, so stepping lines from a paused video stays paused.
|
||||
* Resolves once every command has been sent; it never rejects.
|
||||
*/
|
||||
export function dispatchConfiguredMpvCommand(
|
||||
command: MpvCommand,
|
||||
deps: {
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
sendMpvCommand: (command: MpvCommand) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!isSubtitleSeekCommand(command)) {
|
||||
deps.sendMpvCommand(command);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// The fallback only covers a failed pause lookup, so a failed re-pause never resends the seek.
|
||||
return deps
|
||||
.getPlaybackPaused()
|
||||
.then(
|
||||
(paused) => {
|
||||
deps.sendMpvCommand(command);
|
||||
if (paused !== false) {
|
||||
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
deps.sendMpvCommand(command);
|
||||
},
|
||||
)
|
||||
.catch((error: unknown) => console.error('Could not send mpv command', error));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user