Compare commits

..
13 Commits
Author SHA1 Message Date
sudacode 4d5ea8801f feat(anki): add Senren scene-switching field grouping
- Support auto, manual, and disabled Senren duplicate-card merges
- Group sentence, furigana, audio, picture, and miscInfo fields
2026-09-01 23:22:54 -07:00
sudacode b58f391b42 fix: handle notification changes and timing review text safely
- Dismiss stale overlay progress when notification mode changes
- Validate and forward combined timing review text
- Keep macOS timing preview socket paths within system limits
2026-08-30 02:49:37 -07:00
sudacode 90a5dcd2af fix(anki): move line boundary markers with added subtitle lines 2026-08-30 02:38:28 -07:00
sudacode 989122c006 test(anki): mock note deletion in manual update tests 2026-08-30 02:03:02 -07:00
sudacode ad1c1aea1b feat(anki): add multi-line subtitle selection to media timing review 2026-08-30 01:56:16 -07:00
sudacode 62d645e577 fix: apply CodeRabbit auto-fixes 2026-08-30 01:56:16 -07:00
sudacode f36e7f6182 feat(anki): add runtime toggle for media timing review
- Add session-only runtime option with live config hot-reload support
- Document the runtime palette toggle
2026-08-30 01:56:16 -07:00
sudacode 475f81d96d fix(anki): allow timing review cards without media
- Keep existing or create new cards without generating audio or images
- Refine timing review timeline expansion labels and boundary markers
2026-08-30 01:56:16 -07:00
sudacode 046ab59da9 fix(anki): clarify media timing review subtitle boundaries
- Compact the timing review layout
- Label mined subtitle line start and end boundaries
2026-08-30 01:56:16 -07:00
sudacode e91f031faa fix(anki): keep media timing review keyboard-focused
- Route modal keydown events before later modal handlers
- Restore focus and preserve visible focus styling
2026-08-30 01:56:16 -07:00
sudacode ec6a106748 fix(anki): prevent media timing review hangs and invalid ranges
- Reject stale or invalid timing actions
- Fall back to original timing when the renderer stops responding
2026-08-30 01:56:16 -07:00
sudacode ca27a4275f feat(anki): add waveform-guided timing controls
- Add speech-weighted waveform analysis and playback playhead
- Support dragging, sliding, and keyboard nudging for clip timing
2026-08-30 01:56:16 -07:00
sudacode 256eb5da1b feat(anki): add media timing review before card creation
- Add draggable timing previews with audio playback and exact media ranges
- Support cancellation choices, including deleting existing cards
2026-08-30 01:56:16 -07:00
97 changed files with 783 additions and 3734 deletions
+2 -5
View File
@@ -60,11 +60,8 @@ jobs:
- name: Install Lua
run: |
# Lua needs only Ubuntu sources; unrelated runner repositories can be unavailable.
test -f /etc/apt/sources.list.d/ubuntu.sources
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
sudo apt-get "${apt_sources[@]}" update
sudo apt-get "${apt_sources[@]}" install -y lua5.4
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
-31
View File
@@ -1,36 +1,5 @@
# Changelog
## v0.19.6 (2026-09-04)
### Added
- **Card Timing Review**:
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
- **Senren Field Grouping**:
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
### Changed
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
### Fixed
- **Subtitle & Mining Accuracy**:
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
## v0.19.5 (2026-08-30)
### Fixed
+6 -6
View File
@@ -34,14 +34,14 @@
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.6",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
@@ -226,7 +226,7 @@
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.4", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.47", "ws": "^8.20.0" } }, "sha512-ff0uEXuibh9wi+l4vOj7xInLUjtlTaQBje/SCyQkeXZ0j2V0y+Zge5PQIQFRHH9TjjGaYJkTofEcQhncM2q7/w=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
@@ -406,7 +406,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -498,7 +498,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Dismissed active overlay card-update progress when notification settings switch to OSD before an update finishes.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines.
@@ -1,4 +0,0 @@
type: fixed
area: startup
- Fixed first launch exiting on macOS when the SubMiner config directory did not yet exist by creating it before acquiring the startup lock.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: mining
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
- The timing review can pull any number of previous and next subtitle lines onto the card: `P`/`N` (or the Prev/Next steppers) add lines one at a time, Shift removes them, the sentence preview highlights exactly what the card will contain, and the clip range and line boundary markers on the waveform follow the added lines automatically.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: mining
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: anki
- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries.
- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time).
-4
View File
@@ -1,4 +0,0 @@
type: added
area: overlay
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays.
+1 -1
View File
@@ -1,4 +1,4 @@
# SubMiner docs
# SubMiner Docs
In-repo VitePress documentation source for SubMiner.
+19 -19
View File
@@ -1,10 +1,10 @@
# AniList integration
# AniList Integration
SubMiner syncs your watch progress to [AniList](https://anilist.co). Finish an episode and it reads the title and episode number off the filename, finds the matching AniList entry, and updates your progress through the GraphQL API. A failed update retries in the background with exponential backoff.
SubMiner can sync your watch progress to [AniList](https://anilist.co) automatically. When you finish an episode, SubMiner detects the title and episode number from the filename, finds the matching AniList entry, and updates your progress via the GraphQL API. Failed updates are retried with exponential backoff in the background.
The same AniList data feeds [cover art](#cover-art) in the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
AniList data also powers two additional features: [cover art](#cover-art) for the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
[AniList](https://anilist.co) is a free anime tracking site. The **access token** is a private key SubMiner keeps so it can update your list for you. You approve it once during setup, and your AniList password never touches SubMiner.
[AniList](https://anilist.co) is a free website for tracking which anime you have watched. An **access token** is a private key SubMiner stores so it can update your list on your behalf - you approve it once during setup, and you never paste a password into SubMiner.
## Setup
@@ -32,18 +32,18 @@ If the embedded auth UI fails to render, SubMiner opens the authorize URL in you
You can also set `anilist.accessToken` directly in config to skip the setup flow entirely. When blank, SubMiner uses the locally stored encrypted token.
:::
## How tracking works
## How Tracking Works
SubMiner watches playback and pushes an AniList progress update once an episode counts as watched. That means at least 85% of its duration, and at least 10 minutes either way.
SubMiner monitors playback and triggers an AniList progress update when an episode is considered "watched" -- at least 85% of the episode duration viewed and a minimum of 10 minutes watched.
The update flow:
1. **Title detection** - SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** - The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons - sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
3. **Progress check** - SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** - A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
## Update queue and retry
## Update Queue and Retry
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
@@ -58,7 +58,7 @@ After 8 failed attempts, the update is moved to a dead-letter queue and no longe
Use `--anilist-retry-queue` to manually process one ready item from the queue.
## Cover art
## Cover Art
SubMiner fetches cover art from AniList for display in the stats dashboard. When a new video starts playing, the cover art fetcher:
@@ -71,11 +71,11 @@ A no-match result is cached for 5 minutes before SubMiner retries, preventing re
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
## Rate limiting
## Rate Limiting
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
## Configuration reference
## Configuration Reference
```jsonc
{
@@ -107,7 +107,7 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
## CLI commands
## CLI Commands
| Command | Description |
| ----------------------- | ------------------------------------------------------------- |
@@ -124,10 +124,10 @@ There is no `characterDictionary.enabled` key: character dictionary sync is enab
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
- **Cover art missing:** Cover art is fetched on a best-effort basis using title matching. If the filename is hard to parse, the search may return no results. The fetcher retries after 5 minutes.
- **Encryption unavailable on Linux:** If you see warnings about safeStorage, try `--password-store=basic_text` as a workaround, or start your desktop keyring (gnome-keyring, KWallet).
- **Encryption unavailable on Linux:** If you see warnings about safeStorage, try `--password-store=basic_text` as a workaround, or ensure your desktop keyring (gnome-keyring, KWallet) is running.
## Related
- [Character Dictionary](/character-dictionary) - AniList-powered character name dictionary for Yomitan
- [Configuration Reference](/configuration) - full config options
- [Jellyfin Integration](/jellyfin-integration) - media server integration
- [Character Dictionary](/character-dictionary) -- AniList-powered character name dictionary for Yomitan
- [Configuration Reference](/configuration) -- full config options
- [Jellyfin Integration](/jellyfin-integration) -- media server integration
+8 -8
View File
@@ -1,8 +1,8 @@
# AniSkip integration
# AniSkip Integration
SubMiner looks up anime intro timings from [AniSkip](https://aniskip.com) so you can jump past the OP with one key.
SubMiner integrates with [AniSkip](https://aniskip.com) to automatically detect anime intro intervals and let you skip them with a single key press.
Intro detection runs in the SubMiner app over the mpv IPC socket. It works whenever the overlay is connected to mpv, not only at launch, and covers every local file loaded during the session including playlist advances.
Intro detection runs in the SubMiner app over the mpv IPC socket. It is available whenever the overlay is connected to mpv - not just at launch - and covers every local file loaded during an mpv session, including playlist advances.
## Setup
@@ -25,9 +25,9 @@ For best title and episode detection, install [`guessit`](https://github.com/gue
python3 -m pip install --user guessit
```
Without `guessit`, SubMiner falls back to its own filename parser. That handles the usual release naming, but unusual formats slip past it.
Without `guessit`, SubMiner falls back to an internal filename parser which handles most common naming conventions but may miss unusual formats.
## How it works
## How It Works
On each local file load:
@@ -39,15 +39,15 @@ On each local file load:
When a custom key (other than `TAB` or `y-k`) is configured, the legacy `y-k` chord is also bound as a fallback skip trigger.
Results are cached per file for the app session. Only a definitive "no intro found" is cached, so a failed lookup gets retried on the next load rather than sticking. If mpv reloads the same file, SubMiner re-applies the chapter markers without hitting the API again.
Results are cached per file for the app session; only definitive "no intro found" results are cached, so transient lookup failures are retried on the next file load. Reload detection is also handled: if mpv reloads the same file, SubMiner re-applies the chapter markers without a new API lookup.
## Triggering from mpv
AniSkip actions are also reachable from mpv script-messages:
You can trigger AniSkip actions from mpv script-messages:
| Command | Effect |
| ------- | ------ |
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
The SubMiner app handles both over the IPC socket.
These are handled by the SubMiner app over the IPC socket.
+87 -57
View File
@@ -1,4 +1,4 @@
# Anki integration
# Anki Integration
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
@@ -19,27 +19,28 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
## Auto-enrichment transport
## Auto-Enrichment Transport
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
**Proxy mode** (default) - SubMiner runs a small local server between Yomitan and Anki. Yomitan sends the new card to SubMiner, SubMiner fills in the media fields, and the finished card goes on to Anki. There is no polling delay.
**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether new cards showed up, then fills them in. Less to configure, at the cost of roughly a 3 second delay.
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
Use proxy mode unless your Yomitan runs in a browser rather than the bundled instance, in which case polling is the simpler path.
Use proxy mode if you want immediate enrichment. Use polling mode if your Yomitan instance is external (browser-based) or you prefer minimal configuration.
In both modes, the enrichment workflow is the same:
1. Checks if a duplicate expression already exists (for field grouping).
2. Updates the sentence field with the current subtitle.
3. Generates and uploads audio and image media.
4. Writes metadata to the miscInfo field.
4. Fills the translation field from the secondary subtitle or AI.
5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks (`added:1`). In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect; stats-dashboard mining also falls back to Yomitan's mining deck when `ankiConnect.deck` is empty.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy mode setup (Yomitan / texthooker)
### Proxy Mode Setup (Yomitan / Texthooker)
```jsonc
"ankiConnect": {
@@ -82,7 +83,7 @@ In Yomitan, go to Settings → Profile and:
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets the active profile when its server is blank or still default.
### Proxy troubleshooting (quick checks)
### Proxy Troubleshooting (quick checks)
If auto-enrichment appears to do nothing:
@@ -106,7 +107,7 @@ curl -sS http://127.0.0.1:8766 \
- Launcher log: `launcher-YYYY-MM-DD.log`
- mpv log: `mpv-YYYY-MM-DD.log`
4. Check that the config JSONC parses and the logging shape is right:
4. Ensure config JSONC is valid and logging shape is correct:
```jsonc
"logging": {
@@ -116,31 +117,30 @@ curl -sS http://127.0.0.1:8766 \
`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
## Field mapping
## Field Mapping
SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`:
```jsonc
"ankiConnect": {
"fields": {
"word": "Expression", // mined word / expression text
"audio": "SentenceAudio", // sentence audio clip cut from the video
"image": "Picture", // screenshot or animated clip
"sentence": "Sentence", // subtitle text
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
"word": "Expression", // mined word / expression text
"audio": "ExpressionAudio", // audio clip from the video
"image": "Picture", // screenshot or animated clip
"sentence": "Sentence", // subtitle text
"miscInfo": "MiscInfo", // metadata (filename, timestamp)
"translation": "SelectionText" // secondary sub or AI translation
}
}
```
`fields.audio` receives the **sentence** audio SubMiner cuts from the video, not word audio. Yomitan writes its own dictionary audio when you mine, so point this at a separate field such as `SentenceAudio` to keep the two apart. The built-in default is still `ExpressionAudio`, which collides with Yomitan on note types that use that field for word audio.
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
### Minimal config
### Minimal Config
If you only want sentence and audio on your cards:
@@ -149,16 +149,14 @@ If you only want sentence and audio on your cards:
"enabled": true,
"fields": {
"sentence": "Sentence",
"audio": "SentenceAudio"
"audio": "ExpressionAudio"
}
}
```
## Media generation
## Media Generation
SubMiner shells out to FFmpeg for audio clips and screenshots, so FFmpeg has to be installed and on `PATH`.
For remote streams such as Jellyfin playback, SubMiner downloads the clip's time window once into a temporary Matroska file (a stream copy, no re-encoding) and reads the timing review waveform, audio preview, audio, and image from that file instead of fetching the stream again for each step. The window covers the clip plus padding, plus the visible timeline in timing review, and grows when you reveal more of the timeline. It is deleted when a different window replaces it, after ten minutes without use, or when SubMiner exits. If the download fails, media generation reads the remote stream directly as before.
SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg must be installed and on `PATH`.
### Audio
@@ -183,27 +181,13 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
The review can also pull adjacent subtitle lines onto the card. Press `P` or `N` (or use the Prev and Next steppers above the sentence preview) to add the previous or next line, as many times as lines are available; Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined line highlighted, so the card's sentence field is always visible before you confirm, and the clip start or end, along with the line-start and line-end rails on the waveform, follows the outermost added line, keeping the review's audio padding. Confirming writes the combined lines to the sentence field; the Reset button drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise only lines that already played are offered, and a clip capped by `media.maxMediaDuration` keeps the full combined sentence even when the audio cannot cover every added line.
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
Canceling the review lets you keep editing, finish with the original timing, keep or create the card without audio or an image, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads. You can also toggle **Review Media Timing** for the current session from the runtime options palette (`Ctrl/Cmd+Shift+O`).
**Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels.
**The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work.
The range you confirm is used exactly as-is; SubMiner does not add audio padding a second time. Static screenshots take its midpoint, and animated AVIF clips cover the whole range.
**Pulling in adjacent lines.** Press `P` or `N`, or use the Prev and Next steppers above the sentence preview, to add the previous or next subtitle line. Repeat for as many lines as exist. Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined one highlighted, so you always see the sentence field before confirming. The clip bounds and the waveform rails follow the outermost added line, keeping the review's audio padding.
Confirming writes the combined lines to the sentence field. Reset drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise you only get lines that already played. A clip capped by `media.maxMediaDuration` still keeps the full combined sentence even when the audio cannot stretch to cover every added line.
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
### Screenshots (static)
### Screenshots (Static)
A single frame is captured at the current playback position.
@@ -220,9 +204,9 @@ A single frame is captured at the current playback position.
}
```
### Animated clips (AVIF)
### Animated Clips (AVIF)
SubMiner can produce an animated AVIF spanning the subtitle duration instead of a still frame.
Instead of a static screenshot, SubMiner can generate an animated AVIF covering the subtitle duration.
```jsonc
"ankiConnect": {
@@ -239,7 +223,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio.
### Behavior options
### Behavior Options
```jsonc
"ankiConnect": {
@@ -260,7 +244,40 @@ When media is available, mined-card overlay and system notifications include the
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
## Sentence cards (Lapis)
## AI Translation
SubMiner can auto-translate the mined sentence and fill the translation field.
Secondary subtitle text still wins when present. AI translation is only attempted when `ankiConnect.ai.enabled` is `true` and no secondary subtitle exists.
```jsonc
"ai": {
"enabled": true,
"apiKey": "sk-...",
"apiKeyCommand": "",
"baseUrl": "https://openrouter.ai/api",
"requestTimeoutMs": 15000
},
"ankiConnect": {
"ai": {
"enabled": true,
"model": "openai/gpt-4o-mini",
"systemPrompt": "Translate mined sentence text only."
}
}
```
`ankiConnect.ai` controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
Provider credentials and request transport settings live in top-level `ai`.
Translation priority:
1. If a secondary subtitle is available, use it as the translation.
2. If `ankiConnect.ai.enabled` is `true` and top-level `ai.enabled` is `true`, call the shared AI provider.
3. If AI translation fails and no secondary subtitle exists, fall back to the original sentence text.
The built-in translation request asks for English output by default. Customize that behavior through `ankiConnect.ai.systemPrompt`.
## Sentence Cards (Lapis)
SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types.
@@ -283,7 +300,7 @@ The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatib
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (19) to select how many recent lines to combine.
## Word card type (Kiku/Lapis)
## Word Card Type (Kiku/Lapis)
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
@@ -298,7 +315,7 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
## Field grouping (Kiku/Senren)
## Field Grouping (Kiku/Senren)
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
@@ -332,20 +349,20 @@ For Senren note types, enable `isSenren` instead. Kiku and Senren write incompat
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
### What gets merged
### What Gets Merged
| Field | Merge behavior |
| -------- | ----------------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
| Field | Merge behavior |
| -------- | --------------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
| MiscInfo | Both cards' source info kept as grouped entries |
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
The merge markup depends on the note type. Kiku entries are wrapped in `<span data-group-id="...">` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries use `group` spans when ordinal order is sufficient and numbered `groupN` spans when they need an absolute scene target. Audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and source `groupN` spans are rebased after the kept note's existing audio scenes.
### Keyboard shortcuts in the modal
### Keyboard Shortcuts in the Modal
| Key | Action |
| ----------- | ---------------------------------- |
@@ -354,7 +371,7 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
| `Backspace` | Go back from the merge preview |
| `Esc` | Cancel (keep both cards unchanged) |
## Full config example
## Full Config Example
```jsonc
{
@@ -372,10 +389,11 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
},
"fields": {
"word": "Expression",
"audio": "SentenceAudio",
"audio": "ExpressionAudio",
"image": "Picture",
"sentence": "Sentence",
"miscInfo": "MiscInfo",
"translation": "SelectionText",
},
"media": {
"generateAudio": true,
@@ -398,6 +416,11 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
"metadata": {
"pattern": "[SubMiner] %f (%t)",
},
"ai": {
"enabled": false,
"model": "", // e.g. "openai/gpt-4o-mini"
"systemPrompt": "",
},
"isKiku": {
"enabled": false,
"fieldGrouping": "disabled",
@@ -408,5 +431,12 @@ The merge markup depends on the note type. Kiku entries are wrapped in `<span da
"sentenceCardModel": "Lapis",
},
},
"ai": {
"enabled": false,
"apiKey": "",
"apiKeyCommand": "",
"baseUrl": "https://openrouter.ai/api",
"requestTimeoutMs": 15000,
},
}
```
+14 -13
View File
@@ -20,7 +20,7 @@ Within the desktop app, `src/main.ts` is a composition root that wires small run
- services compose through explicit inputs/outputs
- orchestration is separate from implementation
## Project structure
## Project Structure
```text
launcher/ # Standalone CLI launcher wrapper and mpv helpers
@@ -33,6 +33,7 @@ plugin/
# state · messages · hover · ui · options · environment · log
# binary · session_bindings · version)
src/
ai/ # AI translation provider utilities (client, config)
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
main.ts # Entry point - delegates to runtime composers/domain modules
preload.ts # Electron preload bridge
@@ -85,7 +86,7 @@ src/
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
```
### Service layer (`src/core/services/`)
### Service Layer (`src/core/services/`)
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
@@ -97,7 +98,7 @@ src/
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
### Renderer layer (`src/renderer/`)
### Renderer Layer (`src/renderer/`)
The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delegated to per-concern modules.
@@ -135,12 +136,12 @@ src/renderer/
platform.ts # Layer/platform capability detection
```
### Launcher + plugin runtimes
### Launcher + Plugin Runtimes
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
- `plugin/subminer/main.lua` is the mpv entrypoint: it sets up the module path and loads `init.lua`, a thin shim that boots the modular Lua files: `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `session_bindings.lua` (configurable session keybindings), `version.lua` (version metadata). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
## Flow diagram
## Flow Diagram
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
@@ -223,7 +224,7 @@ flowchart TB
style ExtRt fill:#363a4f,stroke:#494d64,color:#cad3f5
```
## Composition pattern
## Composition Pattern
Most runtime code follows a dependency-injection pattern:
@@ -260,14 +261,14 @@ Additional conventions in the current code:
- Domain barrels in `src/main/runtime/domains/*` re-export runtime handlers + main-deps builders, while composers in `src/main/runtime/composers/*` assemble larger runtime clusters.
- Many runtime handlers accept `*MainDeps` objects generated by `createBuild*MainDepsHandler` builders to isolate side effects and keep units testable.
### IPC contract + validation boundary
### IPC Contract + Validation Boundary
- Central channel constants live in `src/shared/ipc/contracts.ts` and are consumed by both main (`ipcMain`) and renderer preload (`ipcRenderer`) wiring.
- Runtime payload parsers/type guards live in `src/shared/ipc/validators.ts`.
- Rule: renderer-supplied payloads must be validated at IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before calling domain handlers.
- Malformed invoke payloads return explicit structured errors (for example `{ ok: false, error: ... }`) and malformed fire-and-forget payloads are ignored safely.
### Runtime state ownership (migrated domains)
### Runtime State Ownership (Migrated Domains)
For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
@@ -277,7 +278,7 @@ For domains migrated to reducer-style transitions (for example AniList token/que
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
## Playback startup flow
## Playback Startup Flow
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step - the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
@@ -314,7 +315,7 @@ flowchart TB
The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
## Program lifecycle
## Program Lifecycle
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
@@ -386,7 +387,7 @@ flowchart TB
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
```
## Subtitle prefetch pipeline
## Subtitle Prefetch Pipeline
SubMiner can pre-tokenize upcoming subtitle lines before they appear on screen. When an external subtitle file (SRT, VTT, or ASS) is detected on the active track, the `SubtitlePrefetchService` parses all cues via the subtitle cue parser (`subtitle-cue-parser.ts`), identifies a priority window of upcoming lines based on the current playback position, and tokenizes them in the background through the same pipeline used for live subtitles. Results are stored directly into the `SubtitleProcessingController` cache, so when a subtitle actually appears during playback, it hits a warm cache and renders in ~30-50ms instead of ~200-320ms.
@@ -416,7 +417,7 @@ flowchart TB
style Render stroke-width:2px
```
## Why this design
## Why This Design
- **Smaller blast radius:** changing one feature usually touches one service.
- **Better testability:** most behavior can be tested without Electron windows/mpv.
@@ -427,7 +428,7 @@ flowchart TB
- **Split MPV service layers:** MPV internals are separated into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and properties/render metrics modules for maintainability.
- **Config by domain:** defaults, option registries, and resolution are split by domain under `src/config/definitions/*` and `src/config/resolve/*`, keeping config evolution localized.
## Extension rules
## Extension Rules
- Add behavior to an existing service in `src/core/services/*` or create a focused runtime module under `src/main/runtime/*`; avoid ad-hoc logic in `main.ts`.
- Add new cross-process channels in `src/shared/ipc/contracts.ts` first, validate payloads in `src/shared/ipc/validators.ts`, then wire handlers in IPC runtime modules.
-31
View File
@@ -1,36 +1,5 @@
# Changelog
## v0.19.6 (2026-09-04)
**Added**
- **Card Timing Review**:
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
- **Senren Field Grouping**:
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
**Changed**
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
**Fixed**
- **Subtitle & Mining Accuracy**:
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
## v0.19.5 (2026-08-30)
**Fixed**
+20 -20
View File
@@ -1,12 +1,12 @@
# Character dictionary
# Character Dictionary
SubMiner builds a Yomitan-compatible dictionary of a show's characters from [AniList](https://anilist.co), the online anime and manga database. Once it is loaded, character names in subtitles get recognized and highlighted, and hovering one shows the portrait, role, voice actor, and biography without leaving the overlay.
SubMiner can build a Yomitan-compatible character dictionary from [AniList](https://anilist.co) metadata so that character names in subtitles are recognized, highlighted, and enrichable with context - portraits, roles, voice actors, and biographical detail - without leaving the overlay. (AniList is an online anime/manga database; SubMiner pulls each show's character list from it.)
Proper names rarely appear in ordinary dictionaries, so without this every character name reads as an unknown word. That wrecks N+1 highlighting, since a line naming two characters looks like a line with two unknowns. Recognizing them keeps the highlighting pointed at real vocabulary.
This is helpful because proper names rarely appear in normal dictionaries, so character names would otherwise be flagged as "unknown" words and clutter your mining. Recognizing them keeps your N+1 highlighting focused on real vocabulary.
The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes available for hover-driven Yomitan profile lookup.
## How it works
## How It Works
The feature has three stages: **snapshot**, **merge**, and **match**.
@@ -16,12 +16,12 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
3. **Match** - During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
## Enabling the feature
## Enabling the Feature
Character dictionary sync is disabled by default. To turn it on:
1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
2. Start watching. SubMiner queries AniList's public GraphQL API, which needs no authentication, and imports the merged dictionary into Yomitan.
2. Start watching - SubMiner queries AniList's public GraphQL API (no authentication required) and imports the merged dictionary into Yomitan automatically.
3. Optionally enable **Name Match Images** (Settings → Subtitle Style) to show inline circular character portraits next to matched names in subtitles.
```jsonc
@@ -45,7 +45,7 @@ AniList character data is fetched via public GraphQL queries - no account or acc
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
:::
## Name generation
## Name Generation
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
@@ -56,7 +56,7 @@ A single character produces many searchable terms so that names are recognized r
- Family name alone: 須々木
- Given name alone: 心一
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary. That guess can be ambiguous, since 東紫乃 could be 東+紫乃 or 東紫+乃, so SubMiner generates terms for the top two candidate boundaries and the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
**Middle-dot removal** (common in katakana foreign names):
@@ -86,7 +86,7 @@ Unspaced native names (AniList often stores 渡辺真奈美 without a separator)
This means a character like "太郎" generates entries for 太郎, 太郎さん, 太郎先生, 太郎君, 太郎ちゃん, and so on - all with correct readings.
## Name matching
## Name Matching
Name matching runs inside Yomitan's scanning pipeline during subtitle tokenization.
@@ -109,7 +109,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Inline character portraits
## Inline Character Portraits
When `subtitleStyle.nameMatchImagesEnabled` is enabled, SubMiner injects a small circular portrait image directly into the subtitle line next to each matched character name.
@@ -128,7 +128,7 @@ The portrait size is controlled by the surrounding subtitle font size and render
Inline portraits help you quickly associate names with faces while building vocabulary - especially useful for shows with large casts where you're still learning who's who.
:::
## Dictionary entries
## Dictionary Entries
Each character entry in the Yomitan dictionary includes structured content:
@@ -156,7 +156,7 @@ The three collapsible sections can be configured to start open or closed:
}
```
## Auto-sync lifecycle
## Auto-Sync Lifecycle
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
@@ -185,7 +185,7 @@ These phases are emitted through the configured notification surface. Some phase
The `maxLoaded` setting (default: 3) controls how many media snapshots stay in the active set. When you start a 4th title, the oldest is evicted and the merged dictionary is rebuilt without it.
## Manual generation
## Manual Generation
You can generate a character dictionary from the command line without auto-sync:
@@ -199,7 +199,7 @@ SubMiner.AppImage --dictionary
This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
## Correcting AniList matches
## Correcting AniList Matches
SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
@@ -223,11 +223,11 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
```
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override - including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
## Managing loaded entries
## Managing Loaded Entries
Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
@@ -237,7 +237,7 @@ Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`).
The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
## File structure
## File Structure
All character dictionary data lives under `{userData}/character-dictionaries/`:
@@ -267,7 +267,7 @@ merged.zip
img/ # Embedded character and VA portraits
```
## Configuration reference
## Configuration Reference
| Option | Default | Description |
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
@@ -280,11 +280,11 @@ merged.zip
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
## Reference implementation
## Reference Implementation
SubMiner's character dictionary builder is inspired by the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project - a standalone Rust web service that generates Yomitan character dictionaries from AniList and VNDB data.
The reference implementation covers the same ground: name variant generation, honorific expansion, structured Yomitan content, and portrait embedding. It also reads VNDB as a source for visual novel characters. Key differences:
The reference implementation covers similar ground - name variant generation, honorific expansion, structured Yomitan content, portrait embedding - and additionally supports VNDB as a data source for visual novel characters. Key differences:
| | SubMiner | Reference Implementation |
| ---------------------- | -------------------------------------------- | ------------------------------------- |
+94 -45
View File
@@ -8,13 +8,11 @@ outline: [2, 3]
import { withBase } from 'vitepress';
</script>
One file, `config.jsonc`, holds everything. Most of it is also editable from the in-app **Settings** window, so hand-editing is rarely necessary.
SubMiner is configured through a single file (`config.jsonc`). Most settings are also editable from the in-app **Settings** window - you rarely need to edit the file by hand. This page is the full reference: it explains the Settings window, where the config file lives, and documents every option grouped by topic. New to SubMiner? The Quick Start below plus the [Settings window](#settings) cover everything most users need.
This page is the full reference. It covers the Settings window, where the config file lives, and every option grouped by topic. If you are just starting out, the Quick Start below and the [Settings window](#settings) are enough.
## Quick Start
## Quick start
Start here:
For most users, start with this minimal configuration:
```json
{
@@ -37,11 +35,11 @@ Start here:
Use the known-word deck map to choose which Anki decks and note fields feed the known-word cache.
Everything else is optional; the sections below cover it.
Then customize as needed using the sections below.
## Settings
Open the **Settings** window from the tray menu, the app's `--settings` flag, or `subminer settings`. It writes straight to `config.jsonc`, so anything you change there is a normal config edit you can inspect afterward.
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner - all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
@@ -59,11 +57,11 @@ Each field still writes to its current `config.jsonc` path. For example, subtitl
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
## Configuration file
## Configuration File
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
@@ -97,7 +95,7 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
### Hot-reload behavior
### Hot-Reload Behavior
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
@@ -105,7 +103,7 @@ Hot-reloadable settings include subtitle appearance, sidebar controls, keybindin
shortcuts, notifications, logging level, selected source-language preferences,
Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default
mode, and the Anki deck, known-word, N+1, field, sentence-card, and Kiku options
mode, and the Anki deck, known-word, N+1, field, sentence-card, AI, and Kiku options
listed in the reference tables below.
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -113,10 +111,11 @@ When these values change, SubMiner applies them live. Invalid config edits are r
Restart-required changes:
- Any other config sections still require restart.
- Shared top-level `ai` provider settings still require restart.
- AnkiConnect transport/proxy/media/tag fields still require restart unless listed above.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration options Overview
### Configuration Options Overview
The configuration file includes several main sections:
@@ -147,6 +146,7 @@ The configuration file includes several main sections:
**Anki Integration**
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis/Senren note types
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
@@ -168,7 +168,7 @@ The configuration file includes several main sections:
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
- [**Notifications**](#notifications) - Overlay notification placement
## Core settings
## Core Settings
### Logging
@@ -243,13 +243,13 @@ Configure where overlay notification cards appear:
#### Notification history panel
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use, so left when `overlayPosition` is `"top-left"` and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
The equivalent direct CLI command is `--playback-feedback <text>` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config.
### Auto-start overlay
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:
@@ -267,7 +267,7 @@ When you launch through the SubMiner app or the `subminer` wrapper, the launcher
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
### Startup warmups
### Startup Warmups
Control which startup warmups run in the background versus deferring to first real usage:
@@ -293,7 +293,7 @@ Control which startup warmups run in the background versus deferring to first re
Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExtension`, and `subtitleDictionaries`) with `lowPowerMode: false`; Jellyfin remote session warmup is opt-in (`false` by default). Setting a warmup toggle to `false` defers that work until first usage.
### WebSocket server
### WebSocket Server
The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
@@ -357,9 +357,9 @@ See `config.example.jsonc` for detailed configuration options.
| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `false`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
## Subtitle display
## Subtitle Display
### Subtitle style
### Subtitle Style
Customize the appearance of primary and secondary subtitles:
@@ -457,7 +457,7 @@ Secondary subtitle styling lives in the secondary subtitle CSS object. Any CSS p
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
### Subtitle sidebar
### Subtitle Sidebar
Configure the parsed-subtitle sidebar modal.
@@ -519,7 +519,7 @@ For full details on layout modes, behavior, and the keyboard shortcut, see the [
| `N4` | `#8bd5ca` | JLPT N4 underline color |
| `N5` | `#8aadf4` | JLPT N5 underline color |
### Subtitle position
### Subtitle Position
Set the initial vertical subtitle position (measured from the bottom of the screen):
@@ -537,7 +537,7 @@ Set the initial vertical subtitle position (measured from the bottom of the scre
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
### Secondary subtitles
### Secondary Subtitles
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
@@ -563,6 +563,8 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
**Display modes:**
@@ -573,7 +575,7 @@ The secondary-subtitle language list also acts as the fallback secondary-languag
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
## Keyboard and controls
## Keyboard & Controls
### Keybindings
@@ -639,7 +641,7 @@ Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notifica
**See `config.example.jsonc`** for more keybinding examples and configuration options.
### Shortcuts configuration
### Shortcuts Configuration
Customize or disable the overlay keyboard shortcuts:
@@ -700,7 +702,7 @@ Set any shortcut to `null` to disable it.
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
### Controller support
### Controller Support
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
@@ -816,7 +818,7 @@ If you update this controller documentation or the generated controller examples
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual card update shortcuts
### Manual Card Update Shortcuts
When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
@@ -843,7 +845,7 @@ When automatic card updates are disabled, new cards are detected but not automat
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
### Session help modal
### Session Help Modal
The session help modal opens from the overlay with `Ctrl/Cmd+/` by default. The mpv plugin also exposes it through the `y-h` chord. It shows the current session keybindings and color legend.
@@ -867,7 +869,7 @@ The list is generated at runtime from:
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
### Runtime option palette
### Runtime Option Palette
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
@@ -887,7 +889,39 @@ Palette controls:
- `Enter`: apply selected value
- `Esc`: close
## Anki integration
## Anki Integration
### Shared AI Provider
This is the single, shared connection to an OpenAI-compatible LLM endpoint. Configure it **once** here at the top level, and SubMiner reuses it wherever AI is needed (Anki translation/enrichment and YouTube subtitle fixing). Per-feature toggles and prompt/model tweaks live in their own sections (for example `ankiConnect.ai` and `youtubeSubgen.ai`) and inherit this transport.
```json
{
"ai": {
"enabled": false,
"apiKey": "",
"apiKeyCommand": "",
"model": "openai/gpt-4o-mini",
"baseUrl": "https://openrouter.ai/api",
"requestTimeoutMs": 15000
}
}
```
| Option | Values | Description |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
| `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
| `apiKey` | string | Static API key for the shared provider |
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
SubMiner uses the shared provider for:
- Anki translation/enrichment when Anki AI is enabled
- YouTube generated-subtitle fixing when `youtubeSubgen.fixWithAi` is enabled (with optional `youtubeSubgen.ai.model` / `systemPrompt` overrides)
### AnkiConnect
@@ -909,10 +943,16 @@ Enable automatic Anki card creation and updates with media generation:
"deck": "Learning::Japanese",
"fields": {
"word": "Expression",
"audio": "SentenceAudio",
"audio": "ExpressionAudio",
"image": "Picture",
"sentence": "Sentence",
"miscInfo": "MiscInfo"
"miscInfo": "MiscInfo",
"translation": "SelectionText"
},
"ai": {
"enabled": false,
"model": "",
"systemPrompt": ""
},
"media": {
"generateAudio": true,
@@ -970,10 +1010,14 @@ This example is intentionally compact. The option table below documents availabl
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
@@ -1012,7 +1056,10 @@ This example is intentionally compact. The option table below documents availabl
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
### Kiku/Lapis integration
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
### Kiku/Lapis Integration
SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings.
@@ -1040,7 +1087,7 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
- For [Senren](https://github.com/BrenoAqua/Senren) note types, enable `isSenren` instead of `isKiku`. Duplicate merges then use Senren's scene-switching markup (including grouped `miscInfo` entries), and `isSenren.fieldGrouping` supports the same three modes (default: `auto`). Kiku and Senren are mutually exclusive; if both are enabled, Kiku wins and Senren is turned off with a config warning.
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
### Word card type
### Word Card Type
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
@@ -1054,7 +1101,7 @@ When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrich
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
### N+1 word highlighting
### N+1 Word Highlighting
When known-word highlighting is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
@@ -1091,7 +1138,7 @@ To refresh roughly once per day, set:
}
```
### Field grouping modes
### Field Grouping Modes
| Mode | Behavior |
| ---------- | -------------------------------------------------------------------------------------------------------------------------- |
@@ -1110,7 +1157,7 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
## External integrations
## External Integrations
### Jimaku
@@ -1152,7 +1199,7 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
### Subtitle sync
### Subtitle Sync
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
@@ -1177,6 +1224,8 @@ Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable.
@@ -1340,7 +1389,7 @@ Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same w
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
### Discord rich presence
### Discord Rich Presence
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
@@ -1387,7 +1436,7 @@ Troubleshooting:
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
### Immersion tracking
### Immersion Tracking
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions. This data also powers the stats dashboard:
@@ -1461,7 +1510,7 @@ Set `dbPath` only if you want to relocate the database (for backup, syncing, or
See [Immersion Tracking Storage](/immersion-tracking) for schema details, query templates, dashboard access, retention/rollup behavior, backend portability notes, and the dedicated SQLite verification command.
### Stats dashboard
### Stats Dashboard
Configure the local stats UI served from SubMiner and the in-app stats overlay toggle:
@@ -1492,7 +1541,7 @@ Usage notes:
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
### MPV launcher
### MPV Launcher
Configure the mpv executable, profile, and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
@@ -1534,7 +1583,7 @@ Launch mode behavior:
- **`maximized`** - mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
- **`fullscreen`** - mpv starts in true fullscreen via `--fullscreen`.
### YouTube playback settings
### YouTube Playback Settings
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
@@ -1578,6 +1627,6 @@ Track selection:
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
#### YouTube subtitle generation (`youtubeSubgen`)
#### YouTube Subtitle Generation (`youtubeSubgen`)
An advanced, template-hidden section for Whisper-based YouTube subtitle generation: `whisperBin`, `whisperModel`, `whisperVadModel`, and `whisperThreads` (default `4`). These keys are accepted in `config.jsonc` but the generated template omits them.
An advanced, template-hidden section for Whisper-based YouTube subtitle generation: `whisperBin`, `whisperModel`, `whisperVadModel`, `whisperThreads` (default `4`), and `fixWithAi` (default `false`), which post-processes generated subtitles through the [Shared AI Provider](#shared-ai-provider) with optional `youtubeSubgen.ai.model` / `systemPrompt` overrides. These keys are accepted in `config.jsonc` but intentionally omitted from the generated template.
+9 -11
View File
@@ -1,8 +1,6 @@
# Feature demos
# Feature Demos
Short recordings from real playback sessions.
Some vocabulary for what follows. _Yomitan_ is the pop-up dictionary. _Jimaku_ is a community subtitle database. _alass_ and _ffsubsync_ retime subtitles against the audio. _Jellyfin_ is a self-hosted media server. A _texthooker_ is a web page that mirrors the current subtitle as selectable text so browser tools can read it.
Short recordings of SubMiner's key features and integrations from real playback sessions. A few terms you'll see below: _Yomitan_ is the pop-up dictionary used for word lookups, _Jimaku_ is a community subtitle database, _alass_ and _ffsubsync_ are tools that retime subtitles to match the audio, _Jellyfin_ is a self-hosted media server, and a _texthooker_ is a web page that mirrors the current subtitle as selectable text for browser-based tools.
<script setup>
import { withBase } from 'vitepress';
@@ -10,9 +8,9 @@ import { withBase } from 'vitepress';
const v = '20260819-1';
</script>
## Anki card mining and enrichment
## Anki Card Mining & Enrichment
Mine a card from Yomitan or straight from a subtitle line. SubMiner attaches the sentence, an audio clip cut to the line timing, and a screenshot.
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
@@ -22,9 +20,9 @@ Mine a card from Yomitan or straight from a subtitle line. SubMiner attaches the
</a>
</video>
## Subtitle download and sync
## Subtitle Download & Sync
Search Jimaku, download a track, then retime it with alass or ffsubsync without leaving SubMiner.
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
@@ -34,9 +32,9 @@ Search Jimaku, download a track, then retime it with alass or ffsubsync without
::: info VIDEO COMING SOON
:::
## Jellyfin integration
## Jellyfin Integration
Browse your Jellyfin library, cast to a device, and start playback from SubMiner. Watch progress goes back to the Jellyfin server.
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
@@ -48,7 +46,7 @@ Browse your Jellyfin library, cast to a device, and start playback from SubMiner
## Texthooker
Mirror subtitles to an external texthooker page so browser extensions can read them while the overlay runs.
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
+9 -9
View File
@@ -1,6 +1,6 @@
# Building and testing
# Building & Testing
Architecture and workflow guidance lives in `docs/README.md` at the repo root. This page covers build and test commands only.
For internal architecture/workflow guidance, use `docs/README.md` at the repo root. This page stays focused on contributor-facing build and test commands.
## Prerequisites
@@ -37,7 +37,7 @@ make build-launcher
`bun run build` includes the Yomitan build step. It builds the bundled Chrome extension directly from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun.
## Launcher artifact workflow
## Launcher Artifact Workflow
- Source of truth: `launcher/*.ts`
- Generated output: `dist/launcher/subminer`
@@ -53,7 +53,7 @@ dist/launcher/subminer --help >/dev/null
bash scripts/verify-generated-launcher.sh
```
## Running locally
## Running Locally
```bash
bun run dev # builds + launches with --start --dev
@@ -169,7 +169,7 @@ bun run format:check:src
- `bun run format:check:src` checks the same scoped set without writing changes.
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
## Config generation
## Config Generation
```bash
# Generate default config to ~/.config/SubMiner/config.jsonc (or %APPDATA%\SubMiner\config.jsonc on Windows)
@@ -184,7 +184,7 @@ Convenience wrappers still exist:
- `make generate-config`
- `make generate-example-config`
## Documentation site
## Documentation Site
The docs site now lives in `docs-site/` inside the main repo.
@@ -200,7 +200,7 @@ bun run docs:test # Docs regression tests
Deployment: production docs are built with `bun run docs:build:versioned` and uploaded directly to Cloudflare Pages by the `docs-pages` GitHub Actions workflow using Wrangler (from `.tmp/docs-versioned-site`). Cloudflare's automatic Git-integration deployments are intentionally disabled - see `docs-site/README.md` for the deployment contract. Do not re-enable Pages build settings in the Cloudflare dashboard.
## Makefile reference
## Makefile Reference
Run `make help` for a full list of targets. Key ones:
@@ -216,7 +216,7 @@ Run `make help` for a full list of targets. Key ones:
| `make build-macos` | Convenience wrapper for signed macOS packaging |
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
## Contributor notes
## Contributor Notes
- To add/change a config default, edit the matching domain file in `src/config/definitions/defaults-*.ts`.
- To add/change config option metadata, edit the matching domain file in `src/config/definitions/options-*.ts`.
@@ -228,7 +228,7 @@ Run `make help` for a full list of targets. Key ones:
- Prefer direct inline deps objects in `src/main/` modules for simple pass-through wiring.
- Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping).
## Environment variables
## Environment Variables
| Variable | Description |
| ---------------------------------- | ------------------------------------------------------------------------------ |
+1 -13
View File
@@ -57,19 +57,7 @@ test('docs reflect current launcher and release surfaces', () => {
expect(configurationContents).not.toContain('youtubeSubgen": {\n "mode"');
expect(configurationContents).not.toContain('youtubeSubgen.primarySubLanguages');
expect(configurationContents).toContain('youtube.primarySubLanguages');
// The AI provider still exists in src/ai and ankiConnect.ai, but it is not
// exposed in the Settings window and is not documented for users. Keep the
// user-facing docs free of it so nobody configures a hidden surface.
expect(configurationContents).not.toContain('Shared AI Provider');
expect(configurationContents).not.toContain('ankiConnect.ai');
expect(ankiIntegrationContents).not.toContain('AI Translation');
// ankiConnect.fields.translation is a LEGACY_HIDDEN_CONFIG_PATHS key, so it
// must not be documented as a current setting.
expect(configurationContents).not.toContain('fields.translation');
expect(ankiIntegrationContents).not.toContain('SelectionText');
// fields.audio holds SubMiner's generated sentence audio; examples should not
// point it at the field Yomitan uses for word audio.
expect(ankiIntegrationContents).not.toContain('"audio": "ExpressionAudio"');
expect(configurationContents).toContain('### Shared AI Provider');
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
});
+21 -23
View File
@@ -1,13 +1,13 @@
# Immersion tracking
# Immersion Tracking
SubMiner logs your watching and mining activity to a local SQLite database and shows it in the built-in stats dashboard. Tracking is on by default; turn it off if you would rather not keep the data.
SubMiner can log your watching and mining activity to a local SQLite database, then surface it in the built-in stats dashboard. Tracking is enabled by default and can be turned off if you do not want local analytics.
"Immersion" here means time spent watching and reading native Japanese content. **All of it stays on your machine.** Nothing is uploaded anywhere. SQLite is a single file on disk, so there is no database server to install or run.
"Immersion" here means time spent watching and reading native Japanese content. **All data stays on your computer** - nothing is uploaded anywhere. (SQLite is just a single-file database; you do not need to install or manage anything.)
Each session records watch time, subtitle lines seen, words encountered, and cards mined. SubMiner also keeps exact lifetime summary tables and daily and monthly rollups. Read it through the stats UI, or point any SQLite tool at the file.
When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains exact lifetime summary tables plus daily/monthly rollups. You can view that data in SubMiner's stats UI or query the database directly with any SQLite tool.
::: tip For most users
Leave tracking on and use the [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections below are reference material for querying or tuning the database yourself. Skip them.
Just leave tracking on and use the built-in [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections further down are reference material for advanced users who want to inspect or tune the database - you can safely skip them.
:::
Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_RATIO` (`85%`) value from `src/shared/watch-threshold.ts`.
@@ -25,9 +25,9 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
- Leave `dbPath` empty to use the default location (`immersion.sqlite` in SubMiner's app-data directory).
- Set an explicit path to move the database (useful for backups, cloud syncing, or external tools).
- To share stats and watch history between two machines, use [`subminer sync <host>`](/launcher-script#sync-between-machines) instead of file-level cloud sync. It merges both databases instead of letting one side overwrite the other.
- To share stats and watch history between two machines, use [`subminer sync <host>`](/launcher-script#sync-between-machines) instead of file-level cloud sync — it merges both databases without one side overwriting the other.
## Stats dashboard
## Stats Dashboard
The same immersion data powers the stats dashboard.
@@ -37,7 +37,7 @@ The same immersion data powers the stats dashboard.
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
### Dashboard tabs
### Dashboard Tabs
#### Overview
@@ -70,7 +70,7 @@ Open a title and use **Delete Entry** in its header to remove a mistakenly track
#### Trends
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts. Every chart takes a configurable date range and grouping.
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
![Stats Trends](/screenshots/stats-trends.png)
@@ -108,7 +108,7 @@ Stats server config lives under `stats`:
- `markWatchedKey` toggles the watched state of the highlighted entry inside the stats dashboard.
- `serverPort` controls the localhost dashboard URL.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
- `autoOpenBrowser` decides whether `subminer stats` opens the dashboard URL in your browser once the server is up.
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
- The background stats daemon is separate from the normal SubMiner overlay app, so you can leave it running and still launch SubMiner later to watch or mine from video.
@@ -116,7 +116,7 @@ Stats server config lives under `stats`:
- `subminer stats` fails with an error when `immersionTracking.enabled` is `false`.
- `subminer stats cleanup` defaults to vocabulary cleanup, repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still fail vocab filtering.
## Mining cards from the stats page
## Mining Cards from the Stats Page
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
@@ -126,13 +126,13 @@ The Search tab and the Vocabulary tab's word detail panel both mine from subtitl
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
Secondary subtitle text is stored alongside primary subtitles during playback, but the Search tab does not use it for display or matching.
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
### Word exclusion list
### Word Exclusion List
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
### Repeated line cleanup
### Repeated Line Cleanup
Karaoke openings and animated signs are authored as one subtitle event per animation frame, all carrying the same text. Playback reports every one of those frames, so a single OP lyric could be recorded hundreds of times and dominate "Top Repeated Words".
@@ -162,7 +162,7 @@ The cleanup chains runs per line of text, so interleaved dual-line karaoke colla
Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned.
## Retention defaults
## Retention Defaults
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
@@ -184,9 +184,9 @@ In practice:
- Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs.
- New-word charts use their own permanent lexical daily rollups, which are not pruned by activity-rollup retention.
## Storage / performance model
## Storage / Performance Model
The defaults keep everything, and the schema is shaped around that:
The tracker is optimized for "keep everything" defaults:
- Exact all-time totals live in dedicated lifetime summary tables (`imm_lifetime_global`, `imm_lifetime_anime`, `imm_lifetime_media`).
- Ended-session totals are persisted onto `imm_sessions`, so most dashboard reads do not need to rescan raw telemetry.
@@ -195,7 +195,7 @@ The defaults keep everything, and the schema is shaped around that:
- Cover-art binaries are deduplicated through a shared blob store so episodes in the same series do not each carry duplicate image bytes.
- Hot tables have dedicated indexes for session time ranges, telemetry sample windows, frequency-ranked vocabulary, and cover-art lookup keys.
## Configurable knobs
## Configurable Knobs
All policy options live under `immersionTracking` in your config:
@@ -218,7 +218,7 @@ All policy options live under `immersionTracking` in your config:
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
## Query templates
## Query Templates
### Session timeline
@@ -316,7 +316,7 @@ ORDER BY rollup_month DESC, video_id DESC
LIMIT ?;
```
## Technical details
## Technical Details
- Write path is asynchronous and queue-backed. Hot paths (subtitle parsing, render, token flows) enqueue telemetry and never await SQLite writes.
- Queue overflow policy: drop oldest queued writes, keep newest.
@@ -327,7 +327,7 @@ LIMIT ?;
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
### Schema (v23)
### Schema (v18)
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
@@ -335,8 +335,6 @@ Core tables:
- `imm_videos` - video key/title/source metadata
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
- `imm_session_telemetry` - high-frequency session aggregates over time
- `imm_session_events` - event stream with compact numeric event types
+23 -23
View File
@@ -7,7 +7,7 @@ titleTemplate: Immersion Mining Workflow for MPV
hero:
name: SubMiner
text: Immersion Mining for MPV
tagline: Watch, look up a word, and get an Anki card with audio and a screenshot. Without pausing your show.
tagline: Watch media, mine vocabulary, and craft anki cards without leaving the scene.
image:
src: /assets/SubMiner.png
alt: SubMiner logo
@@ -24,63 +24,63 @@ features:
src: /assets/mpv.svg
alt: mpv icon
title: Built for mpv
details: Reads subtitle state over mpv's IPC socket. Launch with the wrapper script or the mpv plugin. There is no separate bridge process to run.
details: Tracks subtitles via mpv IPC in real time. Launch with the wrapper script or the mpv plugin - no external bridge needed.
link: /usage
linkText: How it works
- icon:
src: /assets/yomitan-icon.svg
alt: Yomitan logo
title: Bundled Yomitan
details: A Yomitan instance is bundled and preconfigured. Hover a word in the subtitle overlay to look it up and mine it.
details: Ships with a built-in Yomitan instance for instant word lookups and context-aware card creation directly from subtitle text.
link: /mining-workflow
linkText: Mining workflow
- icon:
src: /assets/anki-card.svg
alt: Anki card icon
title: Anki card enrichment
details: New cards get the subtitle line, an audio clip cut to the line timing, and a screenshot from that moment.
title: Anki Card Enrichment
details: Auto-fills card fields with sentence, audio clip, screenshot, and translation so you can focus on learning.
link: /anki-integration
linkText: Anki integration
- icon:
src: /assets/highlight.svg
alt: Highlight icon
title: Reading annotations
details: N+1 targeting, character-name matching, frequency highlighting, and JLPT tagging, drawn onto the subtitle line as it plays.
title: Reading Annotations
details: N+1 targeting, character-name matching, frequency highlighting, and JLPT tagging - all layered on subtitle text in real time.
link: /subtitle-annotations
linkText: Annotation details
- icon:
src: /assets/video.svg
alt: Video playback icon
title: YouTube playback
details: Pass a YouTube URL or a ytsearch target. SubMiner picks a subtitle track for the video and loads it.
title: YouTube Playback
details: Play YouTube URLs or ytsearch targets directly - SubMiner automatically selects and loads subtitles for the video.
link: /usage#youtube-playback
linkText: YouTube playback
- icon:
src: /assets/jellyfin.svg
alt: Jellyfin icon
title: Jellyfin integration
details: Browse your Jellyfin library from the overlay and play a title through mpv. Subtitles and mining work the same as with local files.
title: Jellyfin Integration
details: Browse your Jellyfin library, pick media interactively, and play through mpv with full subtitle and mining support.
link: /jellyfin-integration
linkText: Jellyfin setup
- icon:
src: /assets/subtitle-download.svg
alt: Subtitle download icon
title: Subtitle download and sync
details: Search Jimaku or TsukiHime and download a track, then retime it with alass or ffsubsync. Both run from the overlay.
title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync - all from the overlay.
link: /jimaku-integration
linkText: Jimaku integration
- icon:
src: /assets/tokenization.svg
alt: Tracking chart icon
title: Stats dashboard
details: A local dashboard with session history, streak calendars, word frequency, and per-series progress. You can mine cards from lines you already watched.
title: Stats Dashboard
details: Browse session history, streak calendars, vocabulary frequency, and per-series progress in a local dashboard - then mine cards straight from your viewing history.
link: /immersion-tracking
linkText: Dashboard & tracking
- icon:
src: /assets/cross-platform.svg
alt: Cross-platform icon
title: Cross-platform
details: Runs on Linux (Hyprland, Sway, X11), macOS, and Windows. Overlay positioning is handled per compositor rather than assuming one window manager.
title: Cross-Platform
details: Runs on Linux (Hyprland, Sway, X11), macOS, and Windows with compositor-aware window positioning and platform-native integration.
link: /installation
linkText: Platform setup
---
@@ -98,38 +98,38 @@ const demoAssetVersion = '20260819-1';
<div class="workflow-step" style="animation-delay: 0ms">
<div class="step-number">01</div>
<div class="step-title">Start</div>
<div class="step-desc">Launch through the wrapper, or from an mpv setup you already have.</div>
<div class="step-desc">Launch with the wrapper or existing mpv setup and keep subtitles in sync.</div>
</div>
<div class="workflow-connector" aria-hidden="true"></div>
<div class="workflow-step" style="animation-delay: 60ms">
<div class="step-number">02</div>
<div class="step-title">Lookup</div>
<div class="step-desc">Hover a token in the overlay to open the Yomitan popup for that word.</div>
<div class="step-desc">Hover a token in the interactive overlay, then trigger Yomitan lookup to open context.</div>
</div>
<div class="workflow-connector" aria-hidden="true"></div>
<div class="workflow-step" style="animation-delay: 120ms">
<div class="step-number">03</div>
<div class="step-title">Mine</div>
<div class="step-desc">Add the word from Yomitan, or mine the whole line as a sentence card.</div>
<div class="step-desc">Create cards from Yomitan or mine sentence cards directly from subtitle lines.</div>
</div>
<div class="workflow-connector" aria-hidden="true"></div>
<div class="workflow-step" style="animation-delay: 180ms">
<div class="step-number">04</div>
<div class="step-title">Enrich</div>
<div class="step-desc">SubMiner fills in the audio clip, the sentence, and a screenshot from that moment.</div>
<div class="step-desc">Automatically attach timing-accurate audio, sentence text, and visual evidence.</div>
</div>
<div class="workflow-connector" aria-hidden="true"></div>
<div class="workflow-step" style="animation-delay: 240ms">
<div class="step-number">05</div>
<div class="step-title">Track</div>
<div class="step-desc">Review past sessions and word trends, and mine anything you missed the first time.</div>
<div class="step-desc">Open the stats dashboard to review sessions, vocabulary trends, and mine cards from past viewing history.</div>
</div>
</div>
</section>
<section class="demo-section">
<h2>See it in action</h2>
<p>Recorded from an actual playback session: subtitle hover, lookup, and the card that comes out the other end.</p>
<p>Subtitles, lookup flow, and card enrichment from a real playback session.</p>
<div class="demo-window">
<div class="demo-window__bar">
<span class="demo-window__dot"></span>
+23 -25
View File
@@ -1,8 +1,6 @@
# Installation
SubMiner draws an interactive overlay on top of the [mpv](https://mpv.io) video player. While you watch Japanese media, hover any word in the subtitles to look it up, then turn it into an Anki card without switching apps.
Building cards from the content you are actually watching is called **sentence mining**, and it is the whole point of SubMiner. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (the add-on that lets other programs write cards into Anki), so the sentence, audio, and screenshot fields get filled in for you.
SubMiner is a desktop app that draws an interactive layer - an **overlay** - on top of the [mpv](https://mpv.io) video player. As you watch native Japanese media, you can click or hover any word in the subtitles to look it up, then turn it into an Anki flashcard without pausing to switch apps. Building flashcards from real content you're watching is called **sentence mining**, and it's what SubMiner is built for. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (an add-on that lets other programs add cards to Anki) so cards get filled in automatically.
Three steps to get started:
@@ -10,11 +8,11 @@ Three steps to get started:
2. **Install SubMiner** - from the AUR, or download from GitHub Releases
3. **Launch the app** - first-run setup walks you through dictionaries, the launcher, and everything else
## 1. Install requirements
## 1. Install Requirements
Only **mpv** is strictly required. Everything else is optional, though you will want ffmpeg unless you are fine with cards that have no audio or screenshot.
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
Some rows below matter only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut, so skip those.
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
| Dependency | Status | Platforms | What it does |
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -41,7 +39,7 @@ Some rows below matter only for the `subminer` command-line launcher, which is L
- **X11 / Xwayland** - for X11 sessions or any other Wayland compositor (uses `xdotool` and `xwininfo`)
::: warning Wayland support is compositor-specific
Wayland has no universal API for window positioning. Each compositor exposes its own IPC, so SubMiner needs a backend per compositor. Only Hyprland and Sway have native Wayland backends. If you run a different Wayland compositor (GNOME, KDE Plasma, river, etc.), both mpv **and** SubMiner must run under X11 or Xwayland. The `subminer` launcher handles this automatically when `--backend x11` is set or the X11 backend is auto-detected.
Wayland has no universal API for window positioning - each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Only Hyprland and Sway have native Wayland backends. If you run a different Wayland compositor (GNOME, KDE Plasma, river, etc.), both mpv **and** SubMiner must run under X11 or Xwayland. The `subminer` launcher handles this automatically when `--backend x11` is set or the X11 backend is auto-detected.
:::
<details>
@@ -262,7 +260,7 @@ First-run setup can install [Bun](https://bun.sh) and the `subminer` command-lin
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-macos).
:::
### Windows (installer) {#windows-installer}
### Windows (Installer) {#windows-installer}
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
@@ -271,7 +269,7 @@ Download the latest installer from [GitHub Releases](https://github.com/ksyasuda
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
### From source
### From Source
<details>
<summary><b>Linux</b></summary>
@@ -323,9 +321,9 @@ bun run build:win
</details>
## 3. Launch and first-run setup
## 3. Launch & First-Run Setup
Launch SubMiner and the setup wizard opens on its own:
Launch SubMiner and the setup wizard will open automatically:
```bash
# Linux (AUR install)
@@ -352,7 +350,7 @@ The `Finish setup` button requires a config file and at least one Yomitan dictio
> [!TIP]
> You can re-open the setup wizard at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
### Play a video
### Play a Video
Once setup is complete:
@@ -360,13 +358,13 @@ Once setup is complete:
subminer video.mkv
```
The overlay appears over mpv. If a subtitle track loaded, its text shows up in the overlay as hoverable words.
You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay.
On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup - double-click it, or drag a video file onto it.
### Verify setup
### Verify Setup
Run the built-in diagnostic:
Run the built-in diagnostic to confirm everything is working:
```bash
subminer doctor
@@ -374,7 +372,7 @@ subminer doctor
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
## Anki setup (recommended)
## Anki Setup (Recommended)
If you plan to mine Anki cards:
@@ -400,15 +398,15 @@ The tray "Check for Updates" entry installs the new app automatically on Linux,
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
## How it all fits together
## How It All Fits Together
SubMiner is an overlay window that sits on top of mpv. It talks to mpv over an IPC socket, renders each subtitle line as interactive text backed by the bundled Yomitan dictionary engine, and writes Anki cards through AnkiConnect when you ask it to.
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
SubMiner injects the bundled mpv plugin at runtime, so there is nothing to install separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. The plugin adds in-player keybindings (the `y` chord) for driving the overlay from mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
## Platform notes
## Platform Notes
### macOS
@@ -417,9 +415,9 @@ SubMiner injects the bundled mpv plugin at runtime, so there is nothing to insta
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
`mecab` has to be on your PATH when SubMiner launches.
Ensure `mecab` is available on your PATH when launching SubMiner.
**Fullscreen:** The overlay follows mpv into fullscreen. If it does not, accessibility permission is the usual cause.
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
### Windows
@@ -428,7 +426,7 @@ SubMiner injects the bundled mpv plugin at runtime, so there is nothing to insta
- IPC socket on Windows is `\\.\pipe\subminer-socket` - do not use `/tmp/subminer-socket`.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
## Manual launcher install
## Manual Launcher Install
The `subminer` launcher uses a [Bun](https://bun.sh) shebang, so Bun must be installed. First-run setup can handle this automatically, but if you prefer to do it yourself:
@@ -454,9 +452,9 @@ sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/sub
sudo chmod +x /usr/local/bin/subminer
```
## Optional extras
## Optional Extras
### Linux support assets
### Linux Support Assets
SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
+11 -11
View File
@@ -1,10 +1,10 @@
# IPC + runtime contracts
# IPC + Runtime Contracts
SubMiner's Electron app runs two isolated processes, main and renderer, and IPC channels are the only way they talk. That boundary is deliberate. The renderer is an untrusted surface: it loads Yomitan, renders subtitle text SubMiner did not write, and runs in a Chromium sandbox. Every message crossing the bridge goes through a validator before any domain code sees it.
SubMiner's Electron app runs two isolated processes - main and renderer - that can only communicate through IPC channels. This boundary is intentional: the renderer is an untrusted surface (it loads Yomitan, renders user-controlled subtitle text, and runs in a Chromium sandbox), so every message crossing the bridge passes through a validation layer before it can reach domain logic.
Channel names, payload shapes, and validators all live together, so they change together. Touching an IPC surface means updating the contract, the validator, the preload bridge, and the handler in one commit. Drift between those four layers is a bug, not a style preference.
The contract system enforces this by making channel names, payload shapes, and validators co-located and co-evolved. A change to any IPC surface touches the contract, the validator, the preload bridge, and the handler in the same commit - drift between any of those layers is treated as a bug.
## Message flow
## Message Flow
Renderer-initiated calls (`invoke`) pass through four boundaries before reaching a service. Fire-and-forget messages (`send`) follow the same path but skip the response leg. Malformed payloads are caught at the validator and never reach domain code.
@@ -36,7 +36,7 @@ flowchart TB
style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
```
## Runtime sockets
## Runtime Sockets
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
@@ -67,7 +67,7 @@ flowchart LR
How these sockets are established during launch is covered in [Playback Startup Flow](./architecture#playback-startup-flow).
## Core surfaces
## Core Surfaces
| File | Role |
| --- | --- |
@@ -79,7 +79,7 @@ How these sockets are established during launch is covered in [Playback Startup
| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
## Contract rules
## Contract Rules
These rules exist to prevent a class of bugs where the renderer and main process silently disagree about message shapes - which surfaces as undefined fields, swallowed errors, or state corruption.
@@ -89,13 +89,13 @@ These rules exist to prevent a class of bugs where the renderer and main process
- **Keep payloads narrow.** Send only what the handler needs. Avoid passing entire state objects across the bridge - it couples the renderer to internal main-process structure.
- **Co-evolve all layers.** When a payload shape changes, update `contracts.ts`, `validators.ts`, `preload.ts`, and the handler in the same commit. Partial updates are treated as bugs.
## Two message patterns
## Two Message Patterns
**Invoke (request/response):** The renderer calls a typed bridge method and awaits a result. The main process validates the payload, runs the handler, and returns a structured response. Used for operations where the renderer needs a result - lookups, config reads, mining actions.
**Fire-and-forget (send):** The renderer sends a message with no response. The main process validates and handles it silently. Malformed payloads are dropped. Used for notifications where the renderer doesn't need confirmation - UI state hints, focus events, position updates.
## Add a new IPC action
## Add a New IPC Action
1. Add the channel constant in `src/shared/ipc/contracts.ts`.
2. Add or extend the payload validator in `src/shared/ipc/validators.ts`.
@@ -104,7 +104,7 @@ These rules exist to prevent a class of bugs where the renderer and main process
5. Add tests for both valid and malformed payload cases in `src/core/services/*`.
6. Update renderer tests when behavior or state transitions change.
## Runtime state notes
## Runtime State Notes
- Prefer runtime/domain composition via `src/main/runtime/composers/*` and `src/main/runtime/domains/*`. IPC handlers should delegate to composers rather than containing orchestration logic.
- Route shared mutable state updates through transition helpers in `src/main/state.ts` for migrated domains. Direct mutation from IPC handlers bypasses invariant checks.
@@ -116,7 +116,7 @@ These rules exist to prevent a class of bugs where the renderer and main process
- **Renderer invoke fails:** Verify the preload bridge method exists and matches the channel constant. Check that the handler is registered and returning (not throwing).
- **Contract drift:** When invoke calls return unexpected shapes, compare the shared contract, validator, preload bridge, and main handler signatures side by side. One of them was updated without the others.
## Related docs
## Related Docs
- [Architecture](/architecture)
- [Development](/development)
+9 -9
View File
@@ -1,12 +1,12 @@
# Jellyfin integration
# Jellyfin Integration
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server, a private streaming service for video you already own. If your anime lives on a Jellyfin server, SubMiner plays episodes from it through mpv with the mining overlay attached.
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server - think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay.
::: tip Who needs this?
This page only matters if you already run a Jellyfin server or have access to one. Watching local files or YouTube? Skip it. Otherwise start with the in-app setup window (`subminer jellyfin`).
This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point.
:::
SubMiner can register itself as a **cast-to-device target**, the way jellyfin-mpv-shim does. Sign in once, turn on discovery, and SubMiner appears in the "Play on" menu of any Jellyfin client, whether that is the web app, your phone, or a TV. Cast an episode and it opens in SubMiner's mpv window with the overlay and Yomitan lookup live.
SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on" / cast menu of any Jellyfin app - web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup.
This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end.
@@ -18,11 +18,11 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
## Quick start
### 1. start SubMiner
### 1. Start SubMiner
Launch SubMiner and leave it in the system tray.
Launch SubMiner so it's running in the system tray.
### 2. sign in to your server
### 2. Sign in to your server
Open the tray menu and click **Configure Jellyfin**. In the window that opens, enter your **Server URL** (for example `http://127.0.0.1:8096`), **Username**, and **Password**, then click **Login**.
@@ -34,14 +34,14 @@ On success, SubMiner:
Reopen this window any time to switch servers or **Logout**.
### 3. turn on discovery
### 3. Turn on discovery
Discovery is what makes SubMiner appear as a cast target. Two ways to enable it:
- **For the current session** - open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.)
- **Automatically on every launch** - already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings).
### 4. cast from any Jellyfin app
### 4. Cast from any Jellyfin app
In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device - SubMiner appears there named after your computer's hostname. Playback opens in SubMiner.
+7 -7
View File
@@ -1,16 +1,16 @@
# Jimaku integration
# Jimaku Integration
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
[Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime - a shared online library of subtitle files contributed by other learners. SubMiner integrates with the Jimaku API so you can search, browse, and download Japanese subtitle files directly from the overlay - no alt-tabbing or manual file management required. Downloaded subtitles are loaded into mpv immediately.
::: tip Prerequisite: a free API key
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
:::
## How it works
## How It Works
The Jimaku integration runs through an in-overlay modal accessible via a keyboard shortcut (`Ctrl+Shift+J` by default).
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number. It handles `S01E03`, `1x03`, `E03`, and dash-separated episode numbers. If the filename yields a high-confidence match (title + episode), SubMiner auto-searches immediately.
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number. Common naming conventions are supported - `S01E03`, `1x03`, `E03`, and dash-separated episode numbers all work. If the filename yields a high-confidence match (title + episode), SubMiner auto-searches immediately.
From there:
@@ -21,7 +21,7 @@ From there:
If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry.
### Modal keyboard shortcuts
### Modal Keyboard Shortcuts
| Key | Action |
| --- | --- |
@@ -64,7 +64,7 @@ The keyboard shortcut is configured separately under `shortcuts`:
}
```
### API key
### API Key
An API key is required to use the Jimaku integration. You can get one from [jimaku.cc](https://jimaku.cc). There are two ways to provide it:
@@ -73,7 +73,7 @@ An API key is required to use the Jimaku integration. You can get one from [jima
If both are set, `apiKey` takes priority.
## Filename parsing
## Filename Parsing
SubMiner extracts media info from the current video path to pre-fill the search fields. The parser handles:
+7 -7
View File
@@ -1,14 +1,14 @@
# Launcher script
# Launcher Script
The `subminer` launcher handles video selection, mpv startup, and overlay management in one script. Use it on Linux and macOS: it is the only path that guarantees mpv comes up with the right IPC socket and SubMiner's defaults. It is a Bun script, shipped as a release asset next to the AppImage and DMG.
The `subminer` launcher is an all-in-one script that handles video selection, mpv startup, and overlay management. It is the recommended way to use SubMiner on Linux and macOS because it guarantees mpv is launched with the correct IPC socket and SubMiner defaults. It's a Bun script distributed as a release asset alongside the AppImage and DMG.
::: tip Windows users
On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup - double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
:::
## Video picker
## Video Picker
Run `subminer` with no file and it opens an interactive picker. That is **fzf** in the terminal by default, or **rofi** with `-R`.
When you run `subminer` without specifying a file, it opens an interactive video picker. By default it uses **fzf** in the terminal; pass `-R` to use **rofi** instead.
### fzf (default)
@@ -66,7 +66,7 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
```
## Watch history
## Watch History
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
@@ -87,7 +87,7 @@ After an episode ends or you close mpv, the launcher returns to an action menu f
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
## Sync between machines
## Sync Between Machines
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app. The command-line launcher is optional everywhere.
@@ -135,7 +135,7 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 60 minutes), including during active playback; results surface as overlay notifications. The unfinished playback session is skipped until a later sync sees it finalized. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
## Common commands
## Common Commands
```bash
subminer video.mkv # play a specific file (managed launches auto-start the visible overlay by default)
+35 -29
View File
@@ -1,20 +1,20 @@
# Mining workflow
# Mining Workflow
This guide walks the whole sentence mining loop, from starting a video to ending up with an Anki card that has audio, a screenshot, and the surrounding sentence.
This guide walks through the sentence mining loop - from watching a video to creating Anki cards with audio, screenshots, and context.
## Overview
_Sentence mining_ means turning sentences you hit while watching native video into Anki cards, so you learn a word in the context where you first met it. The idea is old. The tedious part is everything between spotting the word and having a finished card, and that is the part SubMiner does for you.
_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
SubMiner draws a transparent overlay on top of mpv and renders each subtitle line as interactive text. Hover a word, trigger a Yomitan lookup with your configured key or modifier, then add the card. SubMiner attaches the sentence, an audio clip, and a screenshot on its own, so there is nothing to copy-paste or screenshot by hand.
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
> **Yomitan** is the popup dictionary that shows definitions when you hover or scan a word. **AnkiConnect** is the add-on that lets SubMiner talk to Anki. Both are set up during installation - see [Anki Integration](/anki-integration) if you have not configured them yet.
## Creating Anki cards
## Creating Anki Cards
There are four ways to create or enrich cards, depending on your workflow.
### 1. Auto-update from Yomitan
### 1. Auto-Update from Yomitan
This is the most common flow. Yomitan creates a card in Anki, and SubMiner enriches it automatically.
@@ -27,22 +27,23 @@ This is the most common flow. Yomitan creates a card in Anki, and SubMiner enric
- **Sentence**: The current subtitle line.
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus optional configured padding).
- **Image**: A screenshot or animated clip from the current playback position.
- **Translation**: From the secondary subtitle track, or generated via AI if configured.
- **MiscInfo**: Metadata like filename and timestamp.
Configure which fields to fill in `ankiConnect.fields`. See [Anki Integration](/anki-integration) for details.
### 2. manual update from clipboard
### 2. Manual Update from Clipboard
If you prefer a hands-on approach (animecards-style), you can copy the current subtitle to the clipboard and then paste it onto the last-added Anki card:
1. Add a word via Yomitan as usual.
2. Press `Ctrl/Cmd+C` to copy the current subtitle line to the clipboard.
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio and image, the same fields auto-update would fill.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill.
Manual clipboard updates always replace generated sentence audio in `ankiConnect.fields.audio`, even when `ankiConnect.behavior.overwriteAudio` is disabled. Normal word-card updates use the configured sentence and audio fields even when Lapis or Kiku support is enabled.
Use this when auto-update is off, or when the line you want on the card is not the line currently on screen.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
| Shortcut | Action | Config key |
| -------------------------- | ------------------------------- | --------------------------------------- |
@@ -50,7 +51,7 @@ Use this when auto-update is off, or when the line you want on the card is not t
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |
### 3. mine Sentence (hotkey)
### 3. Mine Sentence (Hotkey)
Create a standalone sentence card without going through Yomitan:
@@ -63,7 +64,7 @@ The sentence card uses the note type configured in `isLapis.sentenceCardModel` a
Sentence card creation requires `ankiConnect.isLapis.sentenceCardModel` to name a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type that exists in Anki (default: `"Lapis"`). See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
:::
### 4. mark as audio card
### 4. Mark as Audio Card
After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+A` by default, `shortcuts.markAudioCard`) to mark the card as an audio card. This sets the audio-card flag and fills sentence, image, and metadata fields alongside the full-subtitle audio clip.
@@ -71,9 +72,9 @@ After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+
Audio card marking uses the same `ankiConnect.isLapis.sentenceCardModel` note type as sentence cards. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
:::
### Field grouping (Kiku/Senren)
### Field Grouping (Kiku/Senren)
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This is built for [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching).
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching).
1. You add a word via Yomitan.
2. SubMiner detects the new card and checks if a card with the same expression already exists.
@@ -83,15 +84,15 @@ If you mine the same word from different sentences, SubMiner can merge the cards
See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku-senren) for configuration options, merge behavior, and modal keyboard shortcuts.
## Overlay model
## Overlay Model
SubMiner uses one overlay window with modal surfaces. It carries two subtitle bars - a primary reading bar and a secondary translation/context bar - plus modal dialogs that open on top.
Toggle the entire overlay window with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
### Primary subtitle layer
### Primary Subtitle Layer
The primary bar renders each subtitle as separate hoverable word spans, each carrying its reading and headword. Its styling is independent of mpv's own subtitle rendering. It supports:
The primary bar renders subtitles as tokenized hoverable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
- Word-level hover targets for Yomitan lookup
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
@@ -100,17 +101,20 @@ The primary bar renders each subtitle as separate hoverable word spans, each car
- Right-click + drag to reposition subtitles
- **Reading annotations** - known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted
### Secondary subtitle bar
### Secondary Subtitle Bar
The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track, usually English, above the primary reading line. Use it to sanity-check your comprehension without breaking out of the mining flow.
The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track (typically English) for translation/context while keeping the primary reading flow below. It is useful for:
- Quick comprehension checks without leaving the mining flow.
- Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
For local media, SubMiner can parse supported embedded secondary tracks into timed cues. For remote URLs and files on network mounts, it uses mpv's live secondary subtitle text instead of scanning the media with ffmpeg.
The `secondarySub` config controls it, and it opens and closes with the main overlay window. Cycle which track feeds it with `Shift+J`.
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
SubMiner collapses duplicate ASS layers in parsed secondary tracks. Exact repeated lines collapse at any length, while distinct simultaneous short lines remain separate. Long dialogue and positioned-sign copies also collapse when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar.
### Display modes
### Display Modes
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
@@ -127,11 +131,11 @@ Cycle each bar's mode at runtime with its own shortcut:
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
### Modal surfaces
### Modal Surfaces
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
## Looking up words
## Looking Up Words
1. Hover over the subtitle area - the overlay activates pointer events.
2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly.
@@ -139,7 +143,7 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
4. Yomitan opens its lookup popup for the hovered token.
5. From the popup, add the word to Anki.
### Controller workflow
### Controller Workflow
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
@@ -151,11 +155,11 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
6. **Close** - press `B` to dismiss the Yomitan popup and return to subtitle navigation.
7. **Pause/resume** - press `L3` (left stick click) to toggle mpv pause at any time.
Once controller support is on, the controller and keyboard both stay live. You can drop the controller mid-episode and keep going with the keyboard. Toggle keyboard-only mode with `Y` on the controller.
After controller support is enabled, the controller and keyboard can be used interchangeably - switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
See [Usage - Controller Support](/usage#controller-support) for setup details and [Configuration - Controller Support](/configuration#controller-support) for the full mapping and tuning options.
## Subtitle sync (subsync)
## Subtitle Sync (Subsync)
If your subtitle file is out of sync with the audio, SubMiner can resynchronize it using [alass](https://github.com/kaegi/alass) or [ffsubsync](https://github.com/smacke/ffsubsync).
@@ -169,22 +173,24 @@ The reference and the out-of-sync subtitle must be different tracks; the referen
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
## Texthooker
SubMiner serves a texthooker UI from a local HTTP server at `http://127.0.0.1:5174`. The port is fixed unless you override it with the mpv plugin's `texthooker_port` script-opt. External tools read subtitle text from it as lines arrive, which is how you would feed a browser-based Yomitan instance.
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (fixed default port; overridable only via the mpv plugin's `texthooker_port` script-opt) that serves a texthooker UI. This allows external tools - such as a browser-based Yomitan instance - to receive subtitle text in real time.
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
If you want to build your own browser client, websocket consumer, or automation relay, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
## Related features
## Related Features
These feed into the mining loop but each has its own page:
These features support the mining loop but have their own dedicated pages:
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - reads your Anki decks and highlights words you already know, so a line with exactly one unknown word stands out while you watch.
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
+10 -10
View File
@@ -1,12 +1,12 @@
# MPV plugin
# MPV Plugin
The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv. It binds in-player keys for controlling the overlay, so start, stop, toggle, and skip-intro all work without leaving the player window.
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
Most people never touch it. Any SubMiner-managed launch, whether from the app, the `subminer` launcher, or the Windows shortcut, injects the bundled plugin for that session, and nothing lands in mpv's global `scripts` directory. Keep reading if you launch mpv from some other tool and still want the in-player controls, or you want to script mpv against SubMiner.
**Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
The plugin is a modular Lua package under `plugin/subminer/`. `main.lua` is the entry point and loads `init.lua` plus its sibling modules. Earlier releases installed a single global `main.lua`; runtime loading replaced that.
The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `main.lua`, which loads `init.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
## Runtime loading
## Runtime Loading
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
@@ -67,7 +67,7 @@ The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMi
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
## Shared shortcuts (session bindings)
## Shared Shortcuts (Session Bindings)
The `y-*` chords above are built into the plugin. Everything else you configure under [`shortcuts.*`](/shortcuts) - plus any custom [`keybindings`](/configuration) and the stats toggle/mark-watched keys - is **injected into mpv at runtime**, so the same shortcut works both inside mpv and in the SubMiner overlay. You do not edit any mpv config to enable them.
@@ -104,7 +104,7 @@ SubMiner:
Select an item by pressing its number.
## Binary auto-detection
## Binary Auto-Detection
When `binary_path` is empty, the plugin searches platform-specific locations:
@@ -131,7 +131,7 @@ A PowerShell system lookup runs first (running SubMiner process, registry App Pa
On Windows the plugin also normalizes a Unix-style `socket_path` (`/tmp/subminer-socket`) to the named pipe `\\.\pipe\subminer-socket` at runtime.
## Backend detection
## Backend Detection
When `backend=auto`, the plugin detects the window manager:
@@ -145,7 +145,7 @@ When `backend=auto`, the plugin detects the window manager:
Native Wayland support is only available for Hyprland and Sway. If you use a different Wayland compositor, auto-detection will fall back to X11 - both mpv and SubMiner must be running under Xwayland, and `xdotool` and `xwininfo` must be installed.
:::
## Script messages
## Script Messages
The plugin can be controlled from other mpv scripts or the mpv command line using script messages:
@@ -189,7 +189,7 @@ For how the plugin's auto-start fits into the full launch sequence - including w
- **MPV shutdown**: The plugin clears its hover/OSD/gate state on shutdown; the overlay app notices the closed IPC socket and shuts itself down.
- **Texthooker**: When `texthooker_enabled=yes`, the plugin appends `--texthooker` to the overlay start command so the app starts the texthooker server alongside the overlay.
## Using with the `subminer` wrapper
## Using with the `subminer` Wrapper
The `subminer` wrapper script handles mpv launch, socket setup, and overlay lifecycle automatically. You do not need the plugin if you always use the wrapper.
+9 -11
View File
@@ -1,4 +1,4 @@
# Keyboard shortcuts
# Keyboard Shortcuts
This page is the complete reference for every keystroke SubMiner responds to. If you are just getting started, focus on the **Mining Shortcuts** and **Overlay Controls** sections - those cover the day-to-day mining loop. The rest can wait until you need them.
@@ -10,7 +10,7 @@ A few terms used throughout:
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
## App-wide shortcuts
## App-Wide Shortcuts
| Shortcut | Action | Scope | Configurable |
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
@@ -21,12 +21,10 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
:::
## Mining shortcuts
## Mining Shortcuts
These work when the overlay window has focus.
When text is selected in the [subtitle sidebar](./subtitle-sidebar.md#selecting-and-copying-dialogue), `Ctrl/Cmd+C` copies that selection without timestamps, taking priority over the current-subtitle action. `Escape` clears the sidebar selection.
| Shortcut | Action | Config key |
| ------------------ | ----------------------------------------------- | --------------------------------------- |
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
@@ -39,7 +37,7 @@ When text is selected in the [subtitle sidebar](./subtitle-sidebar.md#selecting-
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1``9` to select the total number of subtitle lines to combine, ending at the current line and moving backward through the subtitle timeline. The current line counts toward the selected total. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
## Overlay controls
## Overlay Controls
These control playback and subtitle display. They require overlay window focus.
@@ -75,7 +73,7 @@ On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configu
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
## Subtitle and feature shortcuts
## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
@@ -99,7 +97,7 @@ The stats toggle is handled inside the focused visible overlay window. It is con
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
## Controller shortcuts
## Controller Shortcuts
These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration.
@@ -110,7 +108,7 @@ These overlay-local shortcuts open controller utilities for the Chrome Gamepad A
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
## MPV plugin chords
## MPV Plugin Chords
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key (the overlay-side chord times out after 1 second; the mpv plugin uses native mpv key sequences).
@@ -130,14 +128,14 @@ The bare `v` plugin binding intentionally overrides mpv's native primary subtitl
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
## Drag-and-drop
## Drag-and-Drop
| Gesture | Action |
| ------------------------- | ------------------------------------------------ |
| Drop file(s) onto overlay | Replace current mpv playlist with dropped files |
| `Shift` + drop file(s) | Append all dropped files to current mpv playlist |
## Customizing shortcuts
## Customizing Shortcuts
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+D"`. Use `null` to disable a shortcut.
+16 -16
View File
@@ -1,20 +1,20 @@
# Subtitle annotations
# Subtitle Annotations
SubMiner annotates subtitle tokens as they appear in the overlay. There are four layers: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**.
SubMiner annotates subtitle tokens in real time as they appear in the overlay. Four annotation layers work together to surface useful context while you watch: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**.
All four are off by default and live under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne`. They are independent, so any combination works.
All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently - you can enable any combination.
::: tip Tokenization
Yomitan is the tokenizer, so the dictionaries you installed there decide where word boundaries fall. Piling on large dictionaries adds noise and slows lookups. Be picky about which ones you install and what order you rank them in.
SubMiner's primary tokenizer is Yomitan itself - subtitle text is tokenized based entirely on the dictionaries you have installed in Yomitan. Installing many large dictionaries can increase noise and slow down lookups, so be selective about which dictionaries you install and their priority order.
:::
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
Kanji vocabulary that MeCab labels `名詞/非自立`, such as `日` or `以外`, remains content for every annotation layer. The `非自立` exclusion only suppresses kana grammar nouns such as `こと` and `もの`.
## N+1 word highlighting
## N+1 Word Highlighting
An N+1 sentence is one where you know every word but a single unknown. Those are the best mining targets, because the rest of the sentence gives you the context for free. SubMiner caches your known vocabulary from Anki and marks the lines that qualify.
N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly.
**How it works:**
@@ -43,9 +43,9 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
:::
## Known-word maturity highlighting
## Known-Word Maturity Highlighting
Maturity highlighting tints each known token by the review state of its Anki cards instead of painting every known word the same color, so you can see how much of a line you actually have down. asbplayer does the same thing.
Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned.
**How it works:**
@@ -81,7 +81,7 @@ bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
## Character-name highlighting
## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
@@ -102,9 +102,9 @@ Character-name matches are built from the active merged SubMiner character dicti
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
## Frequency highlighting
## Frequency Highlighting
Frequency highlighting colors tokens by how common the word is, so a rare word in an otherwise easy line stands out. Ranks come from your installed Yomitan frequency dictionaries, read in priority order. The highest-priority dictionary that has the term wins, lower-priority ones fill in terms it lacks, and occurrence-based dictionaries are skipped.
Frequency highlighting colors tokens based on how common they are, using dictionary frequency rank data. This helps you spot high-value vocabulary at a glance. For each token, ranks from the installed Yomitan frequency dictionaries are consulted in priority order: the highest-priority dictionary that has the term wins, lower-priority dictionaries fill in terms it lacks, and occurrence-based dictionaries are skipped.
**Modes:**
@@ -137,9 +137,9 @@ Frequency highlighting skips tokens that look like non-lexical noise (kana redup
Frequency, JLPT, and N+1 metadata are only shown for tokens that survive the subtitle-annotation noise filter. Standalone grammar tokens like `は`, `です`, and `この` are intentionally left unannotated even if a dictionary can assign them metadata.
:::
## JLPT tagging
## JLPT Tagging
JLPT tagging underlines each token in a color for its JLPT level (N1N5), so the difficulty spread of a line is visible without reading it closely.
JLPT tagging adds colored underlines to tokens based on their JLPT level (N1N5), giving you an at-a-glance sense of difficulty distribution in each subtitle line.
**How it works:**
@@ -164,7 +164,7 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
## Runtime toggles
## Runtime Toggles
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
@@ -177,9 +177,9 @@ These annotation layers can be toggled at runtime via the runtime options palett
(Character-name matching, `subtitleStyle.nameMatchEnabled`, is toggled through config or the Settings window, not the runtime palette.)
A toggle takes effect on the next subtitle line. SubMiner does not re-tokenize the line already on screen.
Toggles only apply to new subtitle lines after the change - the currently displayed line is not re-tokenized in place.
## Rendering priority
## Rendering Priority
When multiple annotations apply to the same token, the visual priority is:
+8 -16
View File
@@ -1,36 +1,28 @@
# Subtitle sidebar
# Subtitle Sidebar
The subtitle sidebar puts the whole parsed cue list for the active subtitle file in a scrollable panel next to mpv. Scroll back through lines you already passed, look ahead at what is coming, and click any cue to seek straight to it. The overlay only ever shows the current line; the sidebar shows the rest.
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if you want to turn it off.
## How it works
## How It Works
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining.
- The sidebar and the overlay share one cue list, so a media change or subtitle source switch updates both at once.
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
The sidebar only opens when a parsed cue list exists. Subtitle sources SubMiner cannot parse, such as embedded ASS tracks that mpv renders itself, leave it empty.
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
## Selecting and copying dialogue
Drag across subtitle text to select an excerpt, including across multiple rows. Scroll to extend a selection through a longer conversation. `Ctrl/Cmd+C` or the **Copy** button copies the highlighted text in subtitle order, without timestamps. Partial first and last lines are preserved, with a blank line between subtitle cues.
Dragging to select does not seek playback. Playback-following auto-scroll stops while you drag or have a selection, so the excerpt stays in view. Press `Escape` to clear the selection. An ordinary click with no selection still seeks to that cue.
Selection survives playback updates and Yomitan popup dismissal. Changing media or subtitle sources, refreshing the cue list, or closing the sidebar clears it. Copying an excerpt does not require creating an Anki card.
## Layout modes
## Layout Modes
Two layout modes are available via `subtitleSidebar.layout`:
**`overlay`** (default) - The sidebar floats over mpv as a panel. It does not affect the player window size or position.
**`embedded`** - Reserves space on the right side of the player and shifts the video area over, giving you a split pane. Use this when you want the cue list up without it covering the video. Positioning depends on the compositor, so switch back to `overlay` if the geometry comes out wrong.
**`embedded`** - Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue.
## Configuration
@@ -85,7 +77,7 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr
| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
## Keyboard shortcut
## Keyboard Shortcut
| Key | Action | Config key |
| --- | ----------------------- | ------------------------------ |
+43 -39
View File
@@ -1,16 +1,14 @@
# Troubleshooting
Almost everything that goes wrong lands in one of three places. The overlay shows but no subtitles arrive, which is [MPV Connection](#mpv-connection). Cards get created but come out empty, which is [AnkiConnect](#ankiconnect). Or hovering a word does nothing, which is [Yomitan](#yomitan).
Common issues and how to resolve them. Most problems fall into one of a few buckets - the overlay shows but subtitles don't (see [MPV Connection](#mpv-connection)), cards aren't being created or come out empty (see [AnkiConnect](#ankiconnect)), or word lookups don't appear (see [Yomitan](#yomitan)). If an error message popped up on screen, search this page for the exact text - most headings below are quoted error strings.
If you got an error message on screen, search this page for its exact text. Most headings below are quoted error strings.
## MPV connection
## MPV Connection
**Overlay starts but shows no subtitles**
SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the socket does not exist or the path does not match, the overlay will appear but subtitles will never arrive.
- Check that mpv is running with `--input-ipc-server=/tmp/subminer-socket`.
- Ensure mpv is running with `--input-ipc-server=/tmp/subminer-socket`.
- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpv.socketPath`).
- The `subminer` wrapper script sets the socket automatically when it launches mpv. If you launch mpv yourself, the `--input-ipc-server` flag is required.
@@ -20,7 +18,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
**"Failed to parse MPV message"**
A malformed JSON line arrived from the mpv socket. SubMiner drops the line and keeps going, so a stray one is harmless. A constant stream of them means something else is writing to the same socket path.
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
## Updates
@@ -87,7 +85,7 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
**Overlay appears but clicks pass through / cannot interact**
- Hover directly over subtitle text. The overlay only takes pointer input while the cursor is over a subtitle.
- Make sure you are hovering over subtitle text - the overlay only becomes interactive when the cursor is over a subtitle.
- On macOS/Windows: toggle the overlay off and back on (`Alt+Shift+O`) to re-enable pointer events.
- On Linux: mouse event handling is unreliable in some Electron/compositor combinations. If clicks consistently fail, toggle the overlay off, click the underlying mpv window, then toggle it back on.
@@ -101,9 +99,9 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
SubMiner positions the overlay by tracking the mpv window. If tracking fails:
- Hyprland: `hyprctl` must be on `PATH`.
- Sway: `swaymsg` must be on `PATH`.
- X11: `xdotool` and `xwininfo` must be installed.
- Hyprland: Ensure `hyprctl` is available.
- Sway: Ensure `swaymsg` is available.
- X11: Ensure `xdotool` and `xwininfo` are installed.
If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset.
@@ -126,12 +124,12 @@ If you installed from the AppImage and see this error, the package may be incomp
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
- Look for "Loaded Yomitan extension" in the terminal output.
- Verify Yomitan loaded successfully - check the terminal output for "Loaded Yomitan extension".
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported.
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
## MeCab / tokenization
## MeCab / Tokenization
**"MeCab not found on system"**
@@ -147,19 +145,19 @@ To install MeCab:
Japanese word boundaries depend on Yomitan parser output. If segmentation seems wrong:
- Check that Yomitan dictionaries are installed and active.
- Japanese text has no spaces, so the parser guesses word boundaries. It gets some of them wrong.
- Verify Yomitan dictionaries are installed and active.
- Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect.
## Character dictionary
## Character Dictionary
Character names from AniList are matched and highlighted in subtitles via the bundled Yomitan. See [Character Dictionary](/character-dictionary) for setup and the full troubleshooting list - the most common issues:
- **Names not highlighting:** Check that `subtitleStyle.nameMatchEnabled` is `true` and that the current media resolved to an AniList entry, since SubMiner needs a media ID to fetch characters. No AniList account or token is needed; character data comes from public GraphQL queries.
- **Inline portraits missing:** Check that `subtitleStyle.nameMatchImagesEnabled` is `true`. AniList also has to return an image, and the download has to succeed while the snapshot is generated.
- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`, and that the current media resolved to an AniList entry (SubMiner needs a media ID to fetch characters). No AniList account or token is required - character data uses public GraphQL queries.
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. Portraits also require AniList to return an image and the download to succeed during snapshot generation.
- **Wrong characters showing:** Open the in-app manager (`Ctrl/Cmd+D`) and use **Override** to pin the correct AniList match for the series.
- **Feature unavailable:** If `yomitan.externalProfilePath` is set, SubMiner runs in read-only external-profile mode and its character-dictionary features are disabled.
## Media generation
## Media Generation
**"FFmpeg not found"**
@@ -195,7 +193,7 @@ This warning refers to the OS-registered shortcut `Alt+Shift+Y` (Yomitan setting
Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use `Alt+Shift+O` (with the overlay or mpv focused) to toggle it and give it focus.
## Subtitle timing
## Subtitle Timing
**"Subtitle timing not found; copy again while playing"**
@@ -207,7 +205,7 @@ This OSD message appears when you try to mine a sentence but SubMiner has no tim
Resume playback and wait for the next subtitle to appear, then try mining again.
## Subtitle sync (subsync)
## Subtitle Sync (Subsync)
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
@@ -231,8 +229,8 @@ Install ffsubsync or configure the path:
If subtitle sync fails (the error message is prefixed with the engine name):
- Select a reference. alass needs either a second subtitle track or the local video file, and it cannot be the track being retimed.
- Check that `ffmpeg` is available, since it extracts the internal subtitle track.
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
- Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
@@ -256,23 +254,23 @@ Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
## Logging and app mode
## Logging and App Mode
- Default log output is `warn`.
- Use `--log-level` for more/less output.
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
## Performance and resource impact
## Performance and Resource Impact
### Where the cost comes from
### At a glance
Idle playback with the overlay up is cheap. The spikes come from:
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- media sync and subtitle tooling (`alass`, `ffsubsync`)
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
- Common spikes come from:
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- media sync and subtitle tooling (`alass`, `ffsubsync`)
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
### If playback feels sluggish
@@ -287,16 +285,19 @@ Idle playback with the overlay up is cheap. The spikes come from:
2. Reduce rendering pressure:
- lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead:
- keep `ankiConnect.media.imageType` set to `static`, since animated AVIF encoding is the most expensive path
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
- lower `ankiConnect.media.imageQuality`
- reduce `ankiConnect.media.maxMediaDuration`
4. Lower integration cost:
- set `immersionTracking.enabled: false` to stop session logging and its database writes
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
### Practical low-impact profile
@@ -319,6 +320,9 @@ Idle playback with the overlay up is cheap. The spikes come from:
"imageType": "static",
"imageQuality": 80,
"maxMediaDuration": 12
},
"ai": {
"enabled": false
}
},
"immersionTracking": {
@@ -334,12 +338,12 @@ Idle playback with the overlay up is cheap. The spikes come from:
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
## Platform-specific
## Platform-Specific
### Linux
- **Wayland (Hyprland/Sway only)**: Native Wayland support covers Hyprland and Sway only. Window tracking shells out to `hyprctl` or `swaymsg`; if neither is on `PATH`, tracking fails silently. Other Wayland compositors such as KDE Plasma and GNOME have no native backend - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-and-other-wayland-compositors)).
- **X11 / Xwayland**: Needs `xdotool`, `xprop`, and `xwininfo`. Without them the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
@@ -397,7 +401,7 @@ SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry
For more details, see the Hyprland docs 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
### KDE Plasma & other Wayland compositors
On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and others), the overlay can only stay above mpv when both processes run under **XWayland** - the Wayland protocol forbids clients from controlling window stacking, so the overlay's "always on top" becomes a no-op on a native Wayland surface.
@@ -419,7 +423,7 @@ Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner use
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
- Force XWayland in your own mpv command, for example `mpv --gpu-context=x11vk,x11egl,x11 <file>`. Launching with `WAYLAND_DISPLAY= mpv <file>` works too, as does setting `gpu-context=x11vk` (Vulkan) or `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
@@ -432,7 +436,7 @@ SubMiner can only detect focus for X11/Xwayland windows in this mode. If a nativ
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
## See also
## See Also
Feature-specific issues are covered in each feature's own page:
+8 -9
View File
@@ -1,6 +1,6 @@
# TsukiHime integration
# TsukiHime Integration
[TsukiHime](https://tsukihime.org) indexes anime torrent releases and pulls every attachment out of the release files, embedded subtitle tracks included, then hosts them for direct download. SubMiner talks to the TsukiHime API, so you can grab subtitles for the episode you are watching from the overlay without a torrent client. The download is decompressed, saved next to the video, and loaded into mpv straight away.
[TsukiHime](https://tsukihime.org) tracks anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the TsukiHime API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
This is the multi-language companion to the [Jimaku integration](/jimaku-integration). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
@@ -12,9 +12,9 @@ TsukiHime replaces [Animetosho](https://animetosho.org), which stops processing
Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is the `xz` binary on your `PATH` - TsukiHime serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
:::
## How it works
## How It Works
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter both the release list and the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Each tab lists only the releases whose reported subtitle languages include the tab's language, so the Japanese tab hides the many releases that ship English subtitles only. Releases and tracks with no language tag stay visible on the secondary tab. If nothing on the active tab qualifies, the status line says so and points at the other tab.
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
@@ -24,9 +24,9 @@ From there:
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
TsukiHime's releases are the same files that circulate as torrents. Pick the release matching your local file, same group and same version, and the timing lines up exactly with no resync. For a raw or a different group's encode, take any release of the episode and fix the offset with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`).
Because releases on TsukiHime are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
### Modal keyboard shortcuts
### Modal Keyboard Shortcuts
| Key | Action |
| ---------------------------- | ------------------------------- |
@@ -38,7 +38,7 @@ TsukiHime's releases are the same files that circulate as torrents. Pick the rel
## Configuration
There is nothing to configure to get started. An optional `tsukihime` section in `config.jsonc` tunes it:
The integration works out of the box. An optional `tsukihime` section in `config.jsonc` tunes it:
```jsonc
{
@@ -66,7 +66,7 @@ The keyboard shortcut is configured separately under `shortcuts`:
Existing Animetosho configuration remains compatible. SubMiner treats the old `animetosho` section and `shortcuts.openAnimetosho` setting as deprecated aliases. When old and current names are both present, `tsukihime` and `shortcuts.openTsukihime` take precedence.
## Other ways to open it
## Other Ways to Open It
- CLI: `subminer --open-tsukihime`
- Keybinding command: bind any key to `["__tsukihime-open"]` in the `keybindings` array
@@ -76,7 +76,6 @@ The previous `--open-animetosho` flag and `__animetosho-open` keybinding command
## Troubleshooting
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
- **"No releases with Japanese subtitles"** - none of the search results carry a Japanese track. Most releases only ship English subtitles; try another search, or use the [Jimaku integration](/jimaku-integration) for Japanese subtitles.
- **"Batch releases are not supported"** - TsukiHime only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
+28 -28
View File
@@ -1,6 +1,6 @@
# Usage
## Quick start
## Quick Start
Play a video with SubMiner:
@@ -10,7 +10,7 @@ subminer video.mkv
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup - double-click it, or drag a video file onto it.
That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socket, and brings up the overlay.
That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically.
> [!IMPORTANT]
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
@@ -36,17 +36,17 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
:::
## How it works
## How It Works
Launching SubMiner wires up mpv and the overlay for you:
When you launch SubMiner, it wires up mpv and the overlay for you:
1. SubMiner starts the overlay app in the background
2. mpv runs with an **IPC socket** at `/tmp/subminer-socket` - a small local channel two programs use to talk to each other, so the overlay can ask mpv what subtitle is on screen right now
3. The overlay connects and subscribes to subtitle changes
Subtitles then render as hoverable word spans, and you mine cards straight from the overlay. [Mining Workflow](/mining-workflow) covers the overlay layout, word lookup, card creation, and annotations.
From there, subtitles render as interactive, hoverable word spans and you mine cards directly from the overlay. For the overlay anatomy and the full mining loop - word lookup, card creation, annotations - see [Mining Workflow](/mining-workflow).
### Ways to launch
### Ways to Launch
| Approach | Use when | How |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
@@ -54,11 +54,11 @@ Subtitles then render as hoverable word spans, and you mine cards straight from
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
The mpv plugin is always available, because SubMiner bundles it and injects it at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Commands
These are the ones you will use day to day. [Launcher Script](/launcher-script#subcommands) has every subcommand and flag.
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
```bash
subminer video.mkv # Play a specific file
@@ -151,7 +151,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
### Logging and app mode
### Logging and App Mode
- `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
@@ -165,7 +165,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug` (or `SubMiner.exe --start --dev --log-level debug` on Windows).
- `--playback-feedback <text>` (also `--playback-feedback=<text>`) sends a non-empty text string through the playback-feedback route used for recording/playback prompts. For example: `SubMiner.AppImage --playback-feedback "your feedback"`.
### Windows mpv shortcut
### Windows mpv Shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
@@ -185,7 +185,7 @@ You can use it three ways:
This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blank to auto-discover from `PATH`, or set it to the full `mpv.exe` path if mpv is installed elsewhere. `SUBMINER_MPV_PATH` is still honored as a fallback.
### Launcher subcommands
### Launcher Subcommands
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
@@ -193,9 +193,9 @@ Every subcommand has its own help page, for example `subminer jellyfin -h`. See
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
### First-run setup
### First-Run Setup
The setup window opens on first launch and on any later launch where setup never finished.
Setup popup appears on first launch, or when setup has not been completed.
You can also open it manually:
@@ -209,7 +209,7 @@ Setup flow:
- config file: create the default config directory and prefer `config.jsonc`
- legacy plugin cleanup: remove detected older global SubMiner mpv plugin files if present (the bundled plugin is injected at runtime automatically)
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
- dictionary check: confirm at least one bundled Yomitan dictionary is present, unless an external Yomitan profile is configured
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
- refresh: re-check dictionary state without restarting
@@ -225,7 +225,7 @@ AniList character dictionary auto-sync (optional):
Use subcommands for Jellyfin workflows (`subminer jellyfin ...`).
Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
### MPV profile example (mpv.conf)
### MPV Profile Example (mpv.conf)
`subminer` passes the following MPV options directly on launch by default:
@@ -264,13 +264,13 @@ secondary-sub-visibility=no
### Yomitan setup
SubMiner bundles its own Yomitan extension for overlay lookups. It is a separate install from any Yomitan you run in a browser, with its own dictionaries and settings.
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yomitan` or `SubMiner.AppImage --yomitan`) and import at least one dictionary in the bundled Yomitan instance.
If you also use Yomitan in a browser, set that profile up separately. It inherits nothing from the bundled instance.
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
### YouTube playback
### YouTube Playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
For YouTube playback, SubMiner resolves subtitle selection during startup while mpv is paused: it auto-selects the default primary subtitle track plus a best-effort secondary track, then resumes when primary subtitles are ready.
@@ -288,7 +288,7 @@ Notes:
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
## Live config reload
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
@@ -305,16 +305,16 @@ Live-updated settings include:
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
- `stats.toggleKey`, `stats.markWatchedKey`
- `youtube.primarySubLanguages`
- most `ankiConnect.*` settings
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Controller support
## Controller Support
SubMiner reads gamepads through the Chrome Gamepad API, so you can mine from the couch. The controller drives the overlay while keyboard-only mode is on.
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
### Getting started
### Getting Started
1. Connect a controller before or after launching SubMiner.
2. Set `controller.enabled` to `true` in your config.
@@ -326,7 +326,7 @@ SubMiner reads gamepads through the Chrome Gamepad API, so you can mine from the
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
### Default button mapping
### Default Button Mapping
| Button | Action |
| ----------------------- | --------------------------------------- |
@@ -340,9 +340,9 @@ By default SubMiner uses the first connected controller after controller support
| `Select` / `Minus` | Quit mpv |
| `L2` / `R2` | Unbound (available for custom bindings) |
The default quit binding uses gamepad button index 6. Pads that follow the W3C standard layout report L2 as index 6 and Select as index 8, so on those controllers quit fires on L2 instead. Remap it with `Alt+C` learn mode.
Note: the default quit binding uses gamepad button index 6. Pads that follow the W3C standard gamepad layout report L2 as index 6 (Select is index 8), so on those controllers the quit action may fire on L2 instead - use `Alt+C` learn mode to remap it for your pad.
### Analog controls
### Analog Controls
| Input | Action |
| --------------------- | --------------------------------------------- |
@@ -351,7 +351,7 @@ The default quit binding uses gamepad button index 6. Pads that follow the W3C s
| Right stick vertical | Jump through Yomitan popup |
| D-pad | Fallback for stick navigation when configured |
Learn mode ignores inputs you are already holding and waits for the next fresh press or axis push, so opening the modal mid-input does not capture whatever your thumb was on.
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration - Controller Support](/configuration#controller-support) for the full options.
@@ -378,7 +378,7 @@ The changelog modal (tray > `View Changelog`) works the same way: it renders ove
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
### Drag-and-drop
### Drag-and-Drop
- Drop video files onto the overlay to replace current playback.
- Hold `Shift` while dropping to append to the playlist instead.
+16 -15
View File
@@ -1,19 +1,19 @@
# WebSocket and texthooker API
# WebSocket / Texthooker API & Integration
This page is for people wiring SubMiner's live subtitle stream into their own tools: a browser tab, an automation script, another mpv plugin. If you only want subtitles in a browser tab for Yomitan, jump to [Texthooker Integration Guide](#texthooker-integration-guide). Everything else here is reference for building a client.
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
SubMiner opens four local integration points:
SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows:
- **Subtitle WebSocket** at `ws://127.0.0.1:6677` by default for plain subtitle pushes.
- **Annotation WebSocket** at `ws://127.0.0.1:6678` by default for token-aware clients.
- **Texthooker HTTP UI** at `http://127.0.0.1:5174` by default for browser-based subtitle consumption.
- **mpv plugin script messages** for in-player automation and extension.
The rest of this page documents each one and shows how to build a consumer for it.
This page documents those integration points and shows how to build custom consumers around them.
## Quick reference
## Quick Reference
| Surface | Default | Purpose |
| --- | --- | --- |
@@ -22,7 +22,7 @@ The rest of this page documents each one and shows how to build a consumer for i
| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
## Enable and configure the services
## Enable and Configure the Services
SubMiner's integration ports are configured in `config.jsonc`. All three services are **off by default** - the block below shows the values to set to turn them on.
@@ -52,9 +52,9 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime - there is no plugin config file to edit.
## Developer API documentation
## Developer API Documentation
### 1. subtitle WebSocket
### 1. Subtitle WebSocket
Use the basic subtitle websocket when you only need the current subtitle line as plain text.
@@ -86,7 +86,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
| `sentence` | string | Plain subtitle text with line breaks represented as `<br>`. No annotation spans or attributes. |
| `tokens` | array | Always empty on the basic subtitle websocket. |
### 2. annotation WebSocket
### 2. Annotation WebSocket
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
@@ -167,7 +167,7 @@ SubMiner also adds tooltip-friendly data attributes when available:
If you need a fully custom UI, ignore `sentence` and render from `tokens` instead.
## Texthooker integration guide
## Texthooker Integration Guide
### When to use the bundled texthooker page
@@ -221,7 +221,7 @@ Here is a minimal browser client for the annotation stream:
</script>
```
### Build a custom node client
### Build a custom Node client
```js
import WebSocket from 'ws';
@@ -245,7 +245,7 @@ ws.on('message', (raw) => {
- Reconnect on disconnect; SubMiner does not manage client reconnects for you.
- Prefer `payload.text` for logging/automation and `payload.sentence` or `payload.tokens` for UI rendering.
## Plugin development
## Plugin Development
SubMiner does **not** currently expose a general-purpose third-party plugin SDK inside the app itself. Today, the supported extension surfaces are:
@@ -309,7 +309,7 @@ Examples:
- local vocabulary capture helper that writes interesting lines to a file
- bridge service that forwards websocket events into your own workflow engine
## Webhook examples
## Webhook Examples
SubMiner does **not** currently send outbound webhooks by itself. The supported pattern is to consume the websocket locally and relay events into another system.
@@ -342,6 +342,7 @@ ws.on('message', async (raw) => {
- **n8n / Make / Zapier relay:** send each subtitle line into an automation workflow for logging, translation, or summarization.
- **Discord / Slack notifier:** post only lines that contain unknown words or N+1 targets.
- **Obsidian / Markdown capture:** append subtitle lines plus token metadata to a daily immersion note.
- **Local LLM pipeline:** trigger a glossary, translation, or sentence-mining workflow whenever a new line arrives.
### Filtering example: only forward N+1 lines
@@ -364,7 +365,7 @@ ws.on('message', async (raw) => {
});
```
## Recommended integration combinations
## Recommended Integration Combinations
- **Browser Yomitan client:** `texthooker` + `annotationWebsocket`
- **Custom dashboard:** `annotationWebsocket` only
@@ -372,7 +373,7 @@ ws.on('message', async (raw) => {
- **mpv-side automation:** mpv plugin script messages + optional websocket relay
- **Webhook-style workflows:** `annotationWebsocket` + your own local relay service
## Related pages
## Related Pages
- [Configuration](/configuration#websocket-server)
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
+27 -27
View File
@@ -1,23 +1,23 @@
# YouTube integration
# YouTube Integration
Play a YouTube URL and SubMiner loads Japanese subtitles for it, so mining works the same as it does on a local file. It probes the available tracks with `yt-dlp`, picks a primary and a secondary, downloads both, and loads them into mpv before playback resumes.
SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you the same sentence-mining overlay experience as local video files. It probes available subtitle tracks via `yt-dlp`, selects the best primary and secondary tracks, downloads them, and loads them into mpv before playback resumes.
## Requirements
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** must be installed and on your `PATH`. yt-dlp is a free command-line tool that reads YouTube video and subtitle info; SubMiner calls it behind the scenes. (`PATH` is the list of folders your system searches for programs - most installers add yt-dlp to it automatically. If yours did not, set `SUBMINER_YTDLP_BIN` to the full path of the yt-dlp binary.)
- mpv with `--input-ipc-server` configured (handled automatically when you launch playback through the `subminer` launcher - no manual setup needed).
## How it works
## How It Works
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
1. **Probe** - `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
2. **Discover** - Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
3. **Select** - SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
4. **Download** - Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
5. **Load** - Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block.
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
5. **Load** --- Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block.
## Pipeline diagram
## Pipeline Diagram
```mermaid
flowchart TD
@@ -42,8 +42,8 @@ flowchart TD
A --> B
B --> C
C --> D
D - startup --> E
D - user request --> F
D -- startup --> E
D -- user request --> F
E --> G
F --> G
G --> H
@@ -52,7 +52,7 @@ flowchart TD
K --> L
```
## Auto-load flow
## Auto-Load Flow
On startup with a YouTube URL:
@@ -66,7 +66,7 @@ On startup with a YouTube URL:
6. Missing tracks are downloaded to a temp directory and loaded via `sub-add`.
7. Playback unpauses once the primary subtitle is ready.
## Manual subtitle picker
## Manual Subtitle Picker
Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay. This lets you:
@@ -80,27 +80,27 @@ card to a success notification after the selected tracks load.
The picker displays each track with its language, kind (manual/auto), and title when available.
## Subtitle format handling
## Subtitle Format Handling
SubMiner handles several YouTube subtitle formats transparently:
| Format | Handling |
| ---------------------- | -------------------------------------------------------- |
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML - converted to VTT automatically |
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
## Card media cache
## Card Media Cache
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
Background cache downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`; set `0` for unlimited) and use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
## Configuration reference
## Configuration Reference
### Primary subtitle languages
### Primary Subtitle Languages
```jsonc
{
@@ -114,11 +114,11 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. |
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track. `primarySubLanguages` does not change which YouTube track is auto-picked.
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track `primarySubLanguages` does not change which YouTube track is auto-picked.
### Secondary subtitle languages
### Secondary Subtitle Languages
YouTube secondary selection is fixed: SubMiner always tries an English track (manual over auto) and loads it when found. The shared `secondarySub` config does not change YouTube track selection. `secondarySubLanguages` and `autoLoadSecondarySub` apply only to local and Jellyfin sidecar selection. `defaultMode` still controls how the loaded secondary bar is displayed:
YouTube secondary selection is fixed: SubMiner always tries an English track (manual over auto) and loads it when found. The shared `secondarySub` config does not change YouTube track selection `secondarySubLanguages` and `autoLoadSecondarySub` apply only to local/Jellyfin sidecar selection — but `defaultMode` still controls how the loaded secondary bar is displayed:
```jsonc
{
@@ -138,10 +138,10 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
## Limitations and troubleshooting
## Limitations and Troubleshooting
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
- **yt-dlp not found**: Install `yt-dlp` and put it on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
@@ -149,10 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
- **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface.
## Related pages
## Related Pages
- [Usage - YouTube Playback](/usage#youtube-playback)
- [Configuration - YouTube Playback Settings](/configuration#youtube-playback-settings)
- [Configuration - Secondary Subtitles](/configuration#secondary-subtitles)
- [Usage --- YouTube Playback](/usage#youtube-playback)
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
- [Keyboard Shortcuts](/shortcuts)
- [Jellyfin Integration](/jellyfin-integration)
-4
View File
@@ -40,7 +40,3 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
- Composition over monoliths
- Pure helpers where possible
- Stable user behavior while internals evolve
Startup resolves and creates the user-data directory in `src/main-entry-runtime.ts`
before the entry process requests Electron's single-instance lock. Main-process
config bootstrap then writes the default config only when no config file exists.
-8
View File
@@ -37,14 +37,6 @@ Read when: you need to find the owner module for a behavior or test surface
## Shared Contract Entry Points
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
identifies the media and subtitle source so renderer selections are invalidated on source changes,
including changes whose cue text and timings are identical. Native selection and clean clipboard
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
The preload bridge writes selections through Electron's clipboard API so copying does not depend
on Chromium document focus or require activating the overlay window.
- Config + app-state contracts: `src/types/config.ts`
- Subtitle/token/media annotation contracts: `src/types/subtitle.ts`
- Runtime/window/controller/Electron bridge contracts: `src/types/runtime.ts`
-2
View File
@@ -23,8 +23,6 @@ Read when: selecting the right verification lane for a change
there instead of copying them into caller workflows.
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
plugin tests run for every pull request and tagged release.
Lua installation uses only the runner's Ubuntu package sources so unrelated
third-party repository failures do not block the gate.
## Default Handoff Gate
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.6",
"version": "0.19.5",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -83,14 +83,14 @@
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.6",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
-51
View File
@@ -1,51 +0,0 @@
## Highlights
### Added
- **Pre-Mining Timing Review**:
- Optional review step before creating word, sentence, or audio cards, with a speech-focused waveform that filters out steady background noise so dialogue is easy to spot.
- The clip end automatically snaps back to where dialogue actually ends, since subtitles often linger after speech stops.
- Drag or use the keyboard to adjust clip boundaries, and preview audio with a sweeping playhead that plays to the true end even on high-latency outputs like Bluetooth headphones.
- Pull extra previous or next subtitle lines onto the card with `P`/`N` (or the Prev/Next steppers); a live preview shows exactly what the card will contain.
- You can cancel and still keep the card without media, and the review can be toggled on or off for the session.
- **Senren Note Type Support**:
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, combining sentence, furigana, audio, picture, and misc-info fields.
- Supports the same auto/manual/disabled grouping modes as Kiku, including the manual merge modal. Senren and Kiku are mutually exclusive, so only one can be enabled at a time.
### Changed
- **Remote Streaming Mining**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream for every step. No action needed; the temporary download is cleaned up automatically after ten minutes of inactivity.
- **TsukiHime Release Picker**: The Japanese and secondary-language tabs now filter releases down to ones that actually carry subtitles for that language, and tell you when none do.
### Fixed
- **Broadcast Caption Accuracy**:
- Japanese caption tracks split across two positioned lines (e.g. Crunchyroll) now merge into one, so mined sentences, the sidebar, and line-break settings treat them as a single line; lines from different speakers or sound effects still stay separate.
- Mining from the overlay no longer picks up a leftover line from the previous caption, so the mined sentence and clip timing match what's actually on screen.
- Copying or mining subtitles no longer includes the separate furigana line that some broadcast subtitle files place above kanji.
- **Multi-line Copy After Seeking**: Selecting multiple subtitle lines to copy or mine now selects backward in timeline order after a seek, rather than in playback encounter order.
- **Overlay Stability**:
- On Hyprland, opening a modal (timing review, Jimaku, session help, and others) over fullscreen mpv no longer makes the overlay flicker while the modal loads.
- Switching secondary subtitle tracks no longer causes mpv's native secondary subtitles to flash on screen.
- **Anki Update Notifications**: Switching notification settings to on-screen display while a card update is still in progress now correctly dismisses the old overlay progress indicator.
- **Jellyfin Subtitles**: Subtitle files now load with zero delay in mpv instead of Jellyfin inferring and applying a sync offset.
## What's Changed
- feat(anki): add media timing review before card creation by @ksyasuda in #203
- fix(jellyfin): stop inferring subtitle delays by @ksyasuda in #227
- feat(anki): support Senren scene-switching field grouping by @ksyasuda in #230
- fix(mining): copy multi-line subtitles backward from current line by @ksyasuda in #231
- fix(subtitles): keep native secondary subtitles hidden by @ksyasuda in #232
- fix(subtitles): drop ASS furigana from recorded cues by @ksyasuda in #233
- fix(subtitles): merge wrapped positioned caption rows by @ksyasuda in #234
- fix(tsukihime): filter releases by subtitle language by @ksyasuda in #235
## 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` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
@@ -306,11 +306,9 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, '', 1700000000, 1700000000, ?)`,
);
db.exec('BEGIN');
for (let index = 0; index < 501; index += 1) {
insertWord.run(`${index}`, `${index}`, index === 500 ? 10_000 : 1);
}
db.exec('COMMIT');
const charts = getVocabularyChartData(db);
-1
View File
@@ -89,7 +89,6 @@ function createControllerConfigFixture() {
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
sourceKey: 'test-subtitles',
cues: [],
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
@@ -23,21 +23,6 @@ describe('buildMediaTimingPreviewArgs', () => {
assert.equal(args.at(-1), '/video/show.mkv');
});
test('keeps source timestamps for cached remote windows', () => {
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
mediaPath: '/tmp/window.mkv',
absoluteTimestamps: true,
});
assert.ok(args.includes('--rebase-start-time=no'));
assert.equal(
buildMediaTimingPreviewArgs('/tmp/review.sock', { mediaPath: '/video/show.mkv' }).includes(
'--rebase-start-time=no',
),
false,
);
});
test('separates an option-like media path without adding optional audio arguments', () => {
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
mediaPath: '--fullscreen',
@@ -204,88 +189,3 @@ test('preview session bounds a connection attempt that never settles', async ()
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
assert.equal(connectAttempts, 1);
});
function createFakeSocket() {
const socket = new EventEmitter() as EventEmitter & {
destroyed: boolean;
write: (data: string) => boolean;
end: () => void;
destroy: () => void;
off: EventEmitter['off'];
};
const writes: string[] = [];
socket.destroyed = false;
socket.write = (data) => {
writes.push(data);
return true;
};
socket.end = () => undefined;
socket.destroy = () => {
socket.destroyed = true;
};
return { socket, writes };
}
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 };
child.kill = () => true;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
let endedCount = 0;
session.onPlaybackEnded(() => {
endedCount += 1;
});
const property = (name: string, data: boolean): string =>
`${JSON.stringify({ event: 'property-change', name, data })}\n`;
await session.start({ mediaPath: '/video/show.mkv' });
assert.deepEqual(
writes.map((line) => JSON.parse(line).command),
[
['observe_property', 1, 'eof-reached'],
['observe_property', 2, 'pause'],
],
);
// 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);
writes.length = 0;
await session.play(12.25, 14.5);
assert.deepEqual(
writes.map((line) => JSON.parse(line).command),
[
['set_property', 'pause', true],
['seek', 12.25, 'absolute+exact'],
['set_property', 'end', '14.500'],
['set_property', 'pause', false],
],
);
// Events may arrive split across chunks. The decoder passing `end` flips eof-reached while
// audio still drains; only the keep-open pause that follows marks the preview as finished.
socket.emit('data', property('eof-reached', false) + property('pause', false).slice(0, 20));
socket.emit('data', property('pause', false).slice(20) + property('eof-reached', true));
assert.equal(endedCount, 0);
socket.emit('data', property('pause', true));
assert.equal(endedCount, 1);
socket.emit('data', property('pause', true));
assert.equal(endedCount, 1);
// Stopping early pauses without an end signal, and a later real EOF is not a preview end.
await session.play(1, 2);
socket.emit('data', property('eof-reached', false) + property('pause', false));
await session.stop();
socket.emit('data', property('pause', true) + property('eof-reached', true));
assert.equal(endedCount, 1);
session.dispose();
});
+2 -81
View File
@@ -8,21 +8,12 @@ import { randomUUID } from 'crypto';
const CONNECT_TIMEOUT_MS = 5_000;
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
const CONNECT_RETRY_MS = 40;
/**
* mpv flips eof-reached as soon as the decoder passes `end`, while its audio buffer is still
* draining; keep-open then pauses once the buffer has played out. A preview has ended when
* both have happened.
*/
const EOF_OBSERVER_ID = 1;
const PAUSE_OBSERVER_ID = 2;
export interface MediaTimingPreviewStartOptions {
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
/** The file keeps source timestamps (a cached remote window); seek with the original times. */
absoluteTimestamps?: boolean;
}
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
@@ -60,9 +51,6 @@ export function buildMediaTimingPreviewArgs(
if (typeof options.volume === 'number' && Number.isFinite(options.volume)) {
args.push(`--volume=${Math.max(0, options.volume)}`);
}
if (options.absoluteTimestamps) {
args.push('--rebase-start-time=no');
}
args.push('--', options.mediaPath);
return args;
}
@@ -101,11 +89,6 @@ export class MediaTimingPreviewSession {
resolve: () => void;
} | null = null;
private disposed = false;
private readBuffer = '';
private playing = false;
private eofReached = false;
private paused = true;
private readonly endedListeners = new Set<() => void>();
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
this.deps = {
@@ -172,11 +155,6 @@ export class MediaTimingPreviewSession {
await this.connectWithRetry(socketPath);
}
/**
* Plays [startTime, endTime) once. mpv stops itself at `end` and, thanks to keep-open,
* pauses after draining the audio device, so the listener hears the whole clip even on
* high-latency outputs. onPlaybackEnded fires when mpv reports the end was reached.
*/
async play(startTime: number, endTime: number): Promise<void> {
if (!this.socket || this.socket.destroyed) {
throw new Error('Preview player is not ready');
@@ -185,68 +163,18 @@ export class MediaTimingPreviewSession {
throw new Error('Preview timing is invalid');
}
this.playing = false;
this.send(['set_property', 'pause', true]);
this.send(['set_property', 'ab-loop-a', startTime]);
this.send(['set_property', 'ab-loop-b', endTime]);
this.send(['seek', startTime, 'absolute+exact']);
// 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.
this.eofReached = false;
this.paused = false;
this.playing = true;
}
async stop(): Promise<void> {
this.playing = false;
if (!this.socket || this.socket.destroyed) return;
this.send(['set_property', 'pause', true]);
}
onPlaybackEnded(listener: () => void): void {
this.endedListeners.add(listener);
}
private finishPlayback(): void {
if (!this.playing) return;
this.playing = false;
for (const listener of this.endedListeners) listener();
}
private handleSocketData(chunk: Buffer | string): void {
this.readBuffer += chunk.toString();
let newline = this.readBuffer.indexOf('\n');
while (newline !== -1) {
const line = this.readBuffer.slice(0, newline).trim();
this.readBuffer = this.readBuffer.slice(newline + 1);
newline = this.readBuffer.indexOf('\n');
if (!line) continue;
let message: unknown;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (
typeof message === 'object' &&
message !== null &&
'event' in message &&
message.event === 'property-change' &&
'name' in message &&
'data' in message
) {
this.handlePropertyChange(message.name, message.data);
}
}
}
private handlePropertyChange(name: unknown, data: unknown): void {
if (name === 'eof-reached') this.eofReached = data === true;
else if (name === 'pause') this.paused = data === true;
else return;
if (this.playing && this.eofReached && this.paused) this.finishPlayback();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
@@ -301,13 +229,6 @@ export class MediaTimingPreviewSession {
throw new Error('Preview session is closed');
}
this.socket = socket;
this.readBuffer = '';
socket.on('data', (chunk: Buffer | string) => {
if (this.socket === socket) this.handleSocketData(chunk);
});
socket.once('close', () => this.finishPlayback());
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
return;
} catch {
if (this.disposed) {
@@ -35,67 +35,16 @@ test('speech waveform maps the selected FFmpeg stream and visible range', () =>
assert.match(args[args.indexOf('-af') + 1] ?? '', /c0=FC/);
});
test('speech waveform seeks cached windows by source timestamps', () => {
const args = buildSpeechWaveformArgs(
{
mediaPath: { path: '/tmp/window.mkv', absoluteTimestamps: true, singleResolvedStream: true },
startTime: 8,
endTime: 15,
},
'downmix',
);
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-t') + 2), [
'-ss',
'8',
'-seek_timestamp',
'1',
'-i',
'/tmp/window.mkv',
'-t',
'7',
]);
assert.equal(args.includes('-map'), false);
});
test('waveform levels rise with loudness and top out at the reference level', () => {
test('waveform peaks are normalized without flattening quieter sections', () => {
const peaks = computeWaveformPeaks(pcm([0, 1_000, -2_000, 4_000, -8_000, 16_000]), 3);
assert.equal(peaks.length, 3);
assert.equal(peaks[0], 0);
assert.ok((peaks[1] ?? 0) > 0);
assert.ok((peaks[0] ?? 0) > 0);
assert.ok((peaks[0] ?? 0) < (peaks[1] ?? 0));
assert.ok((peaks[1] ?? 0) < (peaks[2] ?? 0));
assert.equal(peaks[2], 1);
});
test('waveform flattens steady background noise and keeps speech bursts tall', () => {
// 20 slices of steady noise at a fixed level with an 18 dB louder "speech" burst in the middle.
const noise = 1_000;
const samples: number[] = [];
for (let slice = 0; slice < 20; slice += 1) {
const level = slice >= 8 && slice < 12 ? noise * 8 : noise;
for (let sample = 0; sample < 50; sample += 1) {
samples.push(sample % 2 === 0 ? level : -level);
}
}
const peaks = computeWaveformPeaks(pcm(samples), 20);
for (const [index, peak] of peaks.entries()) {
if (index >= 8 && index < 12) assert.equal(peak, 1);
else assert.equal(peak, 0);
}
});
test('waveform stays flat when the whole range is a single steady level', () => {
const peaks = computeWaveformPeaks(
pcm(Array.from({ length: 400 }, (_, i) => (i % 2 ? 900 : -900))),
40,
);
assert.ok(peaks.every((peak) => peak === 0));
});
test('speech waveform uses a mono downmix when the source has no center activity', async () => {
const calls: string[][] = [];
const peaks = await generateSpeechWaveform(
+12 -39
View File
@@ -1,22 +1,15 @@
import { spawn } from 'node:child_process';
import { normalizeMediaInput, type MediaInput } from '../../media-input';
const WAVEFORM_SAMPLE_RATE = 8_000;
const WAVEFORM_POINT_COUNT = 480;
const WAVEFORM_TIMEOUT_MS = 15_000;
const MAX_WAVEFORM_BYTES = 16 * 1024 * 1024;
// Keep the band where speech intelligibility lives; bass, drums, and hum sit below it.
const SPEECH_FILTER = 'highpass=f=250,lowpass=f=3500';
const NOISE_FLOOR_PERCENTILE = 0.2;
const REFERENCE_PERCENTILE = 0.95;
const NOISE_GATE_DB = 3;
const MIN_DISPLAY_RANGE_DB = 12;
const SILENCE_DB = -100;
const SPEECH_FILTER = 'highpass=f=120,lowpass=f=4000';
const CENTER_CHANNEL_FILTER = `pan=mono|c0=FC,${SPEECH_FILTER}`;
const DOWNMIX_FILTER = `aformat=channel_layouts=mono,${SPEECH_FILTER}`;
export interface SpeechWaveformOptions {
mediaPath: MediaInput;
mediaPath: string;
startTime: number;
endTime: number;
audioStreamIndex?: number;
@@ -29,7 +22,6 @@ export function buildSpeechWaveformArgs(
mode: 'center' | 'downmix',
): string[] {
const duration = options.endTime - options.startTime;
const input = normalizeMediaInput(options.mediaPath);
const args = [
'-hide_banner',
'-nostdin',
@@ -37,9 +29,8 @@ export function buildSpeechWaveformArgs(
'error',
'-ss',
String(options.startTime),
...input.inputArgs,
'-i',
input.path,
options.mediaPath,
'-t',
String(duration),
];
@@ -117,22 +108,11 @@ function runFfmpeg(args: string[]): Promise<Buffer> {
});
}
function percentile(sortedValues: number[], fraction: number): number {
const index = Math.min(sortedValues.length - 1, Math.floor(sortedValues.length * fraction));
return sortedValues[index] ?? SILENCE_DB;
}
/**
* Turns mono PCM into 0..1 display heights. Each point is the RMS level of its slice in
* dB, measured against the clip's own noise floor (a low percentile of the slices), so
* constant background noise draws flat and sustained speech stands out. Peak sampling
* would instead follow music transients and lift the floor to nearly speech height.
*/
export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_COUNT): number[] {
const sampleCount = Math.floor(pcm.byteLength / 2);
if (sampleCount === 0 || pointCount <= 0) return [];
const resolvedPointCount = Math.min(pointCount, sampleCount);
const levelsDb = Array.from({ length: resolvedPointCount }, () => SILENCE_DB);
const peaks = Array.from({ length: resolvedPointCount }, () => 0);
for (let point = 0; point < resolvedPointCount; point += 1) {
const sampleStart = Math.floor((point * sampleCount) / resolvedPointCount);
@@ -140,25 +120,18 @@ export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_CO
sampleStart + 1,
Math.floor(((point + 1) * sampleCount) / resolvedPointCount),
);
let energy = 0;
let peak = 0;
for (let sample = sampleStart; sample < sampleEnd; sample += 1) {
const value = pcm.readInt16LE(sample * 2) / 32_768;
energy += value * value;
peak = Math.max(peak, Math.abs(pcm.readInt16LE(sample * 2)) / 32_768);
}
const rms = Math.sqrt(energy / (sampleEnd - sampleStart));
levelsDb[point] = rms > 0 ? Math.max(SILENCE_DB, 20 * Math.log10(rms)) : SILENCE_DB;
peaks[point] = peak;
}
const sortedLevels = [...levelsDb].sort((left, right) => left - right);
const floorDb = percentile(sortedLevels, NOISE_FLOOR_PERCENTILE) + NOISE_GATE_DB;
const referenceDb = Math.max(
percentile(sortedLevels, REFERENCE_PERCENTILE),
floorDb + MIN_DISPLAY_RANGE_DB,
);
return levelsDb.map(
(levelDb) =>
Math.round(Math.min(1, Math.max(0, (levelDb - floorDb) / (referenceDb - floorDb))) * 1_000) /
1_000,
const sortedPeaks = [...peaks].sort((left, right) => left - right);
const referenceIndex = Math.min(sortedPeaks.length - 1, Math.floor(sortedPeaks.length * 0.95));
const referencePeak = Math.max(sortedPeaks[referenceIndex] ?? 0, 0.01);
return peaks.map(
(peak) => Math.round(Math.sqrt(Math.min(1, peak / referencePeak)) * 1_000) / 1_000,
);
}
@@ -37,15 +37,6 @@ export function handleOverlayWindowBeforeInputEvent(options: {
if (options.kind === 'modal') return false;
if (!options.windowVisible) return false;
// The renderer decides whether Copy targets selected sidebar text or the live cue.
if (
(options.input.control || options.input.meta) &&
!options.input.alt &&
!options.input.shift &&
(options.input.code === 'KeyC' || options.input.key.toLowerCase() === 'c')
)
return false;
if (isKeyboardModeToggleInput(options.input)) {
options.preventDefault();
options.sendKeyboardModeToggleRequested();
-29
View File
@@ -85,35 +85,6 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
assert.deepEqual(calls, []);
});
test('native Copy reaches the renderer before the current-subtitle fallback', () => {
for (const modifier of [{ control: true }, { meta: true }]) {
const handled = handleOverlayWindowBeforeInputEvent({
kind: 'visible',
windowVisible: true,
input: {
type: 'keyDown',
key: 'c',
code: 'KeyC',
isAutoRepeat: false,
isComposing: false,
shift: false,
control: false,
alt: false,
meta: false,
location: 0,
modifiers: [],
...modifier,
},
preventDefault: () => assert.fail('Copy must reach Chromium'),
sendKeyboardModeToggleRequested: () => assert.fail('Unexpected mode toggle'),
sendLookupWindowToggleRequested: () => assert.fail('Unexpected lookup toggle'),
tryHandleOverlayShortcutLocalFallback: () => assert.fail('Renderer owns Copy'),
forwardTabToMpv: () => assert.fail('Unexpected mpv input'),
});
assert.equal(handled, false);
}
});
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
const calls: string[] = [];
@@ -1,253 +0,0 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import {
buildRemoteMediaWindowArgs,
RemoteMediaWindowCache,
REMOTE_MEDIA_WINDOW_MAX_SECONDS,
type RemoteMediaWindowCacheOptions,
} from './remote-media-window-cache';
const SOURCE = {
path: 'https://jellyfin.example/Videos/abc/stream?static=true',
audioStreamIndex: 2,
};
type ExecFileStub = NonNullable<RemoteMediaWindowCacheOptions['execFile']>;
function createStub(options: { fail?: boolean; empty?: boolean; defer?: boolean } = {}) {
const calls: string[][] = [];
const pendingCallbacks: Array<() => void> = [];
const execFile: ExecFileStub = (_file, args, _options, callback) => {
calls.push([...args]);
const finish = (): void => {
const outputPath = args.at(-1);
assert.ok(outputPath);
if (options.fail) {
callback(Object.assign(new Error('boom'), { code: 1 }));
return;
}
if (!options.empty) {
fs.writeFileSync(outputPath, 'mkv', 'utf8');
}
callback(null);
};
if (options.defer) {
pendingCallbacks.push(finish);
} else {
queueMicrotask(finish);
}
};
return {
calls,
execFile,
flush: () => {
for (const finish of pendingCallbacks.splice(0)) finish();
},
};
}
async function withCache(
stubOptions: Parameters<typeof createStub>[0],
cacheOptions: Omit<RemoteMediaWindowCacheOptions, 'execFile' | 'tempDir'>,
run: (cache: RemoteMediaWindowCache, stub: ReturnType<typeof createStub>) => Promise<void>,
): Promise<void> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-window-test-'));
const stub = createStub(stubOptions);
const cache = new RemoteMediaWindowCache({
tempDir,
execFile: stub.execFile,
idleTtlMs: 0,
logDebug: () => undefined,
...cacheOptions,
});
try {
await run(cache, stub);
} finally {
cache.cleanup();
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function argValue(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
return index === -1 ? undefined : args[index + 1];
}
test('buildRemoteMediaWindowArgs stream-copies the window with source timestamps intact', () => {
const args = buildRemoteMediaWindowArgs(
{ ...SOURCE, inputOptions: { reconnect: true, headers: { Referer: 'https://a.example/' } } },
{ startTime: 22.75, endTime: 33 },
'/tmp/window.mkv',
);
const inputIndex = args.indexOf('-i');
assert.equal(args[inputIndex + 1], SOURCE.path);
assert.ok(args.indexOf('-reconnect') < inputIndex);
assert.ok(args.indexOf('-headers') < inputIndex);
assert.equal(argValue(args, '-ss'), '22.75');
assert.equal(argValue(args, '-t'), '10.25');
assert.ok(args.indexOf('-t') < inputIndex);
assert.deepEqual(args.slice(args.indexOf('-map'), args.indexOf('-map') + 4), [
'-map',
'0:v:0?',
'-map',
'0:2',
]);
assert.equal(argValue(args, '-c'), 'copy');
assert.ok(args.includes('-copyts'));
assert.ok(args.includes('-start_at_zero'));
assert.equal(argValue(args, '-f'), 'matroska');
assert.equal(args.at(-1), '/tmp/window.mkv');
});
test('buildRemoteMediaWindowArgs keeps every audio stream when none is selected', () => {
const args = buildRemoteMediaWindowArgs(
{ path: SOURCE.path, audioStreamIndex: null },
{ startTime: 0, endTime: 5 },
'/tmp/window.mkv',
);
assert.equal(args[args.lastIndexOf('-map') + 1], '0:a');
});
test('acquire downloads once and reuses the window for covered ranges', async () => {
await withCache({}, {}, async (cache, stub) => {
const window = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
assert.equal(stub.calls.length, 1);
assert.equal(argValue(stub.calls[0]!, '-ss'), '9.75');
assert.equal(argValue(stub.calls[0]!, '-t'), '5.25');
assert.equal(window.startTime, 9.75);
assert.equal(window.endTime, 15);
assert.equal(window.audioStreamIndex, 2);
assert.ok(fs.existsSync(window.path));
assert.deepEqual(window.media, {
path: window.path,
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
});
assert.equal(await cache.acquire(SOURCE, { startTime: 11, endTime: 15 }), window);
assert.equal(await cache.lookup(SOURCE, { startTime: 12, endTime: 12 }), window);
assert.equal(
await cache.lookup(
{ path: SOURCE.path, audioStreamIndex: null },
{ startTime: 12, endTime: 13 },
),
window,
);
assert.equal(stub.calls.length, 1);
});
});
test('lookup never downloads and misses on other ranges, sources, or audio streams', async () => {
await withCache({}, {}, async (cache, stub) => {
assert.equal(await cache.lookup(SOURCE, { startTime: 10, endTime: 14 }), null);
assert.equal(stub.calls.length, 0);
await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
assert.equal(await cache.lookup(SOURCE, { startTime: 14, endTime: 16 }), null);
assert.equal(
await cache.lookup(
{ path: 'https://other.example/stream', audioStreamIndex: 2 },
{
startTime: 11,
endTime: 12,
},
),
null,
);
assert.equal(
await cache.lookup(
{ path: SOURCE.path, audioStreamIndex: 3 },
{ startTime: 11, endTime: 12 },
),
null,
);
assert.equal(stub.calls.length, 1);
});
});
test('acquire widens to the union of the old window and replaces the old file', async () => {
await withCache({}, {}, async (cache, stub) => {
const first = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
const second = await cache.acquire(SOURCE, { startTime: 8, endTime: 12 });
assert.equal(stub.calls.length, 2);
assert.equal(argValue(stub.calls[1]!, '-ss'), '7.75');
assert.equal(second.startTime, 7.75);
assert.equal(second.endTime, 15);
assert.notEqual(second.path, first.path);
assert.equal(fs.existsSync(first.path), false);
assert.ok(fs.existsSync(second.path));
assert.equal(cache.currentWindow, second);
});
});
test('acquire shares an in-flight download between concurrent callers', async () => {
await withCache({ defer: true }, {}, async (cache, stub) => {
const first = cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
await Promise.resolve();
const second = cache.acquire(SOURCE, { startTime: 11, endTime: 13 });
const lookup = cache.lookup(SOURCE, { startTime: 12, endTime: 12 });
await Promise.resolve();
assert.equal(stub.calls.length, 1);
stub.flush();
const [a, b, c] = await Promise.all([first, second, lookup]);
assert.equal(a, b);
assert.equal(a, c);
assert.equal(stub.calls.length, 1);
});
});
test('acquire rejects on ffmpeg failure, leaves no file, and can retry', async () => {
await withCache({ fail: true }, {}, async (cache, stub) => {
await assert.rejects(
cache.acquire(SOURCE, { startTime: 10, endTime: 14 }),
/FFmpeg media window failed: boom/,
);
assert.equal(cache.currentWindow, null);
assert.equal(await cache.lookup(SOURCE, { startTime: 10, endTime: 14 }), null);
await assert.rejects(cache.acquire(SOURCE, { startTime: 10, endTime: 14 }));
assert.equal(stub.calls.length, 2);
});
await withCache({ empty: true }, {}, async (cache) => {
await assert.rejects(
cache.acquire(SOURCE, { startTime: 10, endTime: 14 }),
/exited without creating a media window/,
);
});
});
test('acquire refuses invalid and oversized ranges without spawning ffmpeg', async () => {
await withCache({}, {}, async (cache, stub) => {
await assert.rejects(cache.acquire(SOURCE, { startTime: 10, endTime: 10 }), /invalid/);
await assert.rejects(cache.acquire(SOURCE, { startTime: -1, endTime: 10 }), /invalid/);
await assert.rejects(
cache.acquire(SOURCE, { startTime: 0, endTime: REMOTE_MEDIA_WINDOW_MAX_SECONDS + 1 }),
/too long/,
);
assert.equal(stub.calls.length, 0);
});
});
test('the window is deleted after the idle timeout and on cleanup', async () => {
await withCache({}, { idleTtlMs: 20 }, async (cache) => {
const window = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(cache.currentWindow, null);
assert.equal(fs.existsSync(window.path), false);
const again = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
cache.cleanup();
assert.equal(fs.existsSync(again.path), false);
assert.equal(fs.existsSync(path.dirname(again.path)), false);
});
});
@@ -1,377 +0,0 @@
import { execFile as nodeExecFile, type ExecFileException } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createLogger } from '../../logger';
import { normalizeMediaInput, type MediaInput, type MediaInputOptions } from '../../media-input';
const log = createLogger('media-window');
export const REMOTE_MEDIA_WINDOW_TIMEOUT_MS = 120_000;
export const REMOTE_MEDIA_WINDOW_MAX_SECONDS = 180;
const HEAD_SLACK_SECONDS = 0.25;
const TAIL_SLACK_SECONDS = 1;
const DEFAULT_IDLE_TTL_MS = 10 * 60_000;
const COVERAGE_EPSILON_SECONDS = 0.01;
export interface RemoteMediaWindowSource {
path: string;
inputOptions?: MediaInputOptions;
/** FFmpeg stream index to keep; `null`/undefined keeps every audio stream. */
audioStreamIndex?: number | null;
}
export interface RemoteMediaWindowRange {
startTime: number;
endTime: number;
}
export interface RemoteMediaWindow {
path: string;
startTime: number;
endTime: number;
sourcePath: string;
audioStreamIndex: number | null;
/** Input descriptor for FFmpeg reads; timestamps stay absolute so callers keep source times. */
media: MediaInput;
}
type WindowExecFile = (
file: string,
args: readonly string[],
options: { timeout: number },
callback: (error: ExecFileException | null) => void,
) => void;
export interface RemoteMediaWindowCacheOptions {
tempDir?: string;
execFile?: WindowExecFile;
idleTtlMs?: number;
logDebug?: (message: string) => void;
}
interface PendingFetch extends RemoteMediaWindowRange {
sourcePath: string;
audioStreamIndex: number | null;
promise: Promise<RemoteMediaWindow>;
}
export function isRemoteMediaWindowSourcePath(value: string): boolean {
return /^https?:\/\//i.test(value.trim());
}
function describeSourceForDebugLog(sourcePath: string): string {
try {
return `remote:${new URL(sourcePath).hostname.toLowerCase() || 'unknown'}`;
} catch {
return 'remote:unknown';
}
}
function isUsableRange(range: RemoteMediaWindowRange, allowEmpty: boolean): boolean {
return (
Number.isFinite(range.startTime) &&
Number.isFinite(range.endTime) &&
range.startTime >= 0 &&
(allowEmpty ? range.endTime >= range.startTime : range.endTime > range.startTime)
);
}
function audioStreamMatches(
windowIndex: number | null,
requested: number | null | undefined,
): boolean {
return requested == null || windowIndex === requested;
}
function covers(
candidate: RemoteMediaWindowRange & { sourcePath: string; audioStreamIndex: number | null },
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): boolean {
return (
candidate.sourcePath === source.path &&
audioStreamMatches(candidate.audioStreamIndex, source.audioStreamIndex) &&
candidate.startTime <= range.startTime + COVERAGE_EPSILON_SECONDS &&
candidate.endTime >= range.endTime - COVERAGE_EPSILON_SECONDS
);
}
/**
* Stream-copies `[startTime, endTime]` of a remote source into a local Matroska file.
* `-copyts -start_at_zero` keeps the source timestamps, so later reads seek with the
* original times via `-seek_timestamp 1` (see `MediaInput.absoluteTimestamps`).
*/
export function buildRemoteMediaWindowArgs(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
outputPath: string,
): string[] {
const input = normalizeMediaInput({ path: source.path, inputOptions: source.inputOptions });
const audioMap =
typeof source.audioStreamIndex === 'number' && Number.isInteger(source.audioStreamIndex)
? `0:${source.audioStreamIndex}`
: '0:a';
return [
'-hide_banner',
'-nostdin',
'-loglevel',
'error',
'-ss',
String(range.startTime),
'-t',
String(range.endTime - range.startTime),
...input.inputArgs,
'-i',
input.path,
'-map',
'0:v:0?',
'-map',
audioMap,
'-c',
'copy',
'-sn',
'-dn',
'-copyts',
'-start_at_zero',
'-f',
'matroska',
'-y',
outputPath,
];
}
/**
* Holds one downloaded window of the current remote stream so the timing review,
* audio extraction, and screenshot all read the same local bytes instead of each
* re-fetching the clip over HTTP. A new window replaces the old one; the file is
* deleted after `idleTtlMs` without use, on `clear()`, or on `cleanup()`.
*/
export class RemoteMediaWindowCache {
private readonly tempDir: string;
private readonly execFile: WindowExecFile;
private readonly idleTtlMs: number;
private readonly logDebug: (message: string) => void;
private current: RemoteMediaWindow | null = null;
private pending: PendingFetch | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private sequence = 0;
constructor(options: RemoteMediaWindowCacheOptions = {}) {
this.tempDir = options.tempDir ?? path.join(os.tmpdir(), 'subminer-media-windows');
this.execFile = options.execFile ?? nodeExecFile;
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
this.logDebug = options.logDebug ?? ((message) => log.debug(message));
}
get currentWindow(): RemoteMediaWindow | null {
return this.current;
}
/** Returns a ready or in-flight window covering the range; never starts a download. */
async lookup(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow | null> {
if (!isUsableRange(range, true)) return null;
if (this.current && covers(this.current, source, range)) {
this.touch();
return this.current;
}
const pending = this.pending;
if (pending && covers(pending, source, range)) {
try {
const window = await pending.promise;
this.touch();
return window;
} catch {
return null;
}
}
return null;
}
/** Returns a window covering the range, downloading (and widening) one when needed. */
async acquire(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow> {
if (!isUsableRange(range, false)) {
throw new Error('Media window range is invalid.');
}
if (range.endTime - range.startTime > REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
throw new Error('Media window range is too long to download.');
}
for (;;) {
const hit = await this.lookup(source, range);
if (hit) return hit;
const pending = this.pending;
if (!pending) break;
// Another caller is already downloading; wait for it, then re-check coverage.
await pending.promise.catch(() => null);
}
return this.fetch(source, this.planFetchRange(source, range));
}
clear(): void {
this.cancelIdleTimer();
const current = this.current;
this.current = null;
if (current) this.removeFile(current.path);
}
cleanup(): void {
this.clear();
try {
fs.rmSync(this.tempDir, { recursive: true, force: true });
} catch (error) {
log.error('Failed to cleanup media window directory:', error);
}
}
private planFetchRange(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): RemoteMediaWindowRange {
let startTime = Math.max(0, range.startTime - HEAD_SLACK_SECONDS);
let endTime = range.endTime + TAIL_SLACK_SECONDS;
const current = this.current;
if (
current &&
current.sourcePath === source.path &&
audioStreamMatches(current.audioStreamIndex, source.audioStreamIndex)
) {
// Keep what was already downloaded when the review timeline grows in one direction.
const unionStart = Math.min(startTime, current.startTime);
const unionEnd = Math.max(endTime, current.endTime);
if (unionEnd - unionStart <= REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
startTime = unionStart;
endTime = unionEnd;
}
}
return { startTime, endTime };
}
private fetch(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow> {
fs.mkdirSync(this.tempDir, { recursive: true });
this.sequence += 1;
const outputPath = path.join(this.tempDir, `window_${Date.now()}_${this.sequence}.mkv`);
const audioStreamIndex =
typeof source.audioStreamIndex === 'number' ? source.audioStreamIndex : null;
const description = describeSourceForDebugLog(source.path);
const startedAt = Date.now();
this.logDebug(
`[media-window] fetch start ${description} start=${range.startTime} end=${range.endTime} audioStream=${audioStreamIndex ?? 'all'}`,
);
const promise = new Promise<RemoteMediaWindow>((resolve, reject) => {
this.execFile(
'ffmpeg',
buildRemoteMediaWindowArgs(source, range, outputPath),
{ timeout: REMOTE_MEDIA_WINDOW_TIMEOUT_MS },
(error) => {
const elapsedMs = Math.max(0, Date.now() - startedAt);
const size = error ? 0 : this.fileSize(outputPath);
if (error || size === 0) {
this.removeFile(outputPath);
const reason = error
? error.code === 'ENOENT'
? 'FFmpeg not found. Install FFmpeg to enable media generation.'
: `FFmpeg media window failed: ${error.message}`
: 'FFmpeg exited without creating a media window.';
this.logDebug(`[media-window] fetch failed ${description} elapsedMs=${elapsedMs}`);
reject(new Error(reason));
return;
}
const window: RemoteMediaWindow = {
path: outputPath,
startTime: range.startTime,
endTime: range.endTime,
sourcePath: source.path,
audioStreamIndex,
media: {
path: outputPath,
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
},
};
this.logDebug(
`[media-window] fetch complete ${description} elapsedMs=${elapsedMs} bytes=${size}`,
);
this.replaceCurrent(window);
resolve(window);
},
);
});
const pending: PendingFetch = {
sourcePath: source.path,
audioStreamIndex,
startTime: range.startTime,
endTime: range.endTime,
promise,
};
this.pending = pending;
promise
.catch(() => undefined)
.then(() => {
if (this.pending === pending) this.pending = null;
});
return promise;
}
private replaceCurrent(window: RemoteMediaWindow): void {
const previous = this.current;
this.current = window;
if (previous && previous.path !== window.path) this.removeFile(previous.path);
this.touch();
}
private touch(): void {
this.cancelIdleTimer();
if (this.idleTtlMs <= 0 || !this.current) return;
const timer = setTimeout(() => {
if (this.idleTimer === timer) this.idleTimer = null;
this.clear();
}, this.idleTtlMs);
timer.unref?.();
this.idleTimer = timer;
}
private cancelIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
}
private fileSize(filePath: string): number {
try {
return fs.statSync(filePath).size;
} catch {
return 0;
}
}
private removeFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
log.debug(`Failed to remove media window ${filePath}:`, (error as Error).message);
}
}
}
}
let sharedCache: RemoteMediaWindowCache | null = null;
/** Process-wide cache so the review modal and card media generation share one download. */
export function getSharedRemoteMediaWindowCache(): RemoteMediaWindowCache {
sharedCache ??= new RemoteMediaWindowCache();
return sharedCache;
}
+17 -51
View File
@@ -585,58 +585,24 @@ test('shouldDetachBackgroundLaunch only for first background invocation', () =>
test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', () => {
const calls: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-paths-'));
const configDir = path.posix.join(tempDir, 'SubMiner');
try {
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => {
calls.push(`name:${name}`);
},
setPath: (key, value) => {
calls.push(`path:${key}:${value}`);
},
},
{
platform: 'linux',
homeDir: tempDir,
xdgConfigHome: tempDir,
existsSync: (candidate) =>
candidate === path.posix.join(tempDir, 'subminer', 'config.jsonc'),
},
);
assert.equal(userDataPath, configDir);
assert.deepEqual(calls, ['name:SubMiner', `path:userData:${configDir}`]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('configureEarlyAppPaths creates a fresh macOS config directory before Electron uses it', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-first-launch-'));
const configDir = path.posix.join(homeDir, '.config', 'SubMiner');
try {
const app = {
setName: () => {},
setPath: (_key: 'userData', value: string) => {
assert.equal(value, configDir);
assert.equal(fs.statSync(value).isDirectory(), true);
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => {
calls.push(`name:${name}`);
},
};
const options = { platform: 'darwin', homeDir, xdgConfigHome: '' } satisfies Parameters<
typeof configureEarlyAppPaths
>[1];
assert.equal(fs.existsSync(path.join(homeDir, '.config')), false);
configureEarlyAppPaths(app, options);
setPath: (key, value) => {
calls.push(`path:${key}:${value}`);
},
},
{
platform: 'linux',
homeDir: '/home/tester',
xdgConfigHome: '/tmp/xdg',
existsSync: (candidate) => candidate === '/tmp/xdg/subminer/config.jsonc',
},
);
const configPath = path.join(configDir, 'config.jsonc');
const existingConfig = '{"logging":{"level":"debug"}}\n';
fs.writeFileSync(configPath, existingConfig);
configureEarlyAppPaths(app, options);
assert.equal(fs.readFileSync(configPath, 'utf8'), existingConfig);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
});
-2
View File
@@ -260,8 +260,6 @@ export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPath
existsSync: options?.existsSync ?? fs.existsSync,
});
// The entry process requests its singleton lock before main-process config bootstrap.
fs.mkdirSync(userDataPath, { recursive: true });
app.setName(APP_NAME);
app.setPath('userData', userDataPath);
+12 -72
View File
@@ -235,10 +235,7 @@ import {
createCycleSecondarySubModeRuntimeHandler,
} from './main/runtime/domains/mpv';
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitle,
} from './main/runtime/primary-subtitle-text';
import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text';
import {
createBuildCopyCurrentSubtitleMainDepsHandler,
createBuildHandleMineSentenceDigitMainDepsHandler,
@@ -467,8 +464,6 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
import { getSharedRemoteMediaWindowCache } from './core/services/remote-media-window-cache';
import { resolveMediaGenerationInput } from './anki-integration/media-source';
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
import {
collectMediaTimingContextLines,
@@ -1832,31 +1827,28 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
}
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
// event, so a finished caption row lingering beside a fresh line would end up on the
// card. The parsed view also carries the cue's own timings for the clip range.
const resolved = resolvePrimarySubtitle({
const canonical = resolveCanonicalPrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues,
});
// Same validity bar as the live capture path: an unusable resolved span must fall
// Same validity bar as the live capture path: an unusable canonical span must fall
// back rather than hand mining an empty line or an inverted range.
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
const canonicalText = canonical?.text.trim();
if (
!resolved ||
!resolvedText ||
!Number.isFinite(resolved.startTime) ||
!Number.isFinite(resolved.endTime) ||
resolved.endTime <= resolved.startTime
!canonical ||
!canonicalText ||
!Number.isFinite(canonical.startTime) ||
!Number.isFinite(canonical.endTime) ||
canonical.endTime <= canonical.startTime
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return {
source: 'overlay',
text: resolvedText,
startTime: resolved.startTime,
endTime: resolved.endTime,
text: canonicalText,
startTime: canonical.startTime,
endTime: canonical.endTime,
capturedAtMs: Date.now(),
};
}
@@ -2904,21 +2896,6 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
resolveMediaSource: async () => {
const resolved = await resolveMediaGenerationInput(appState.mpvClient, 'audio', {
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
remoteCacheMode: shouldRequireYoutubeMediaCacheForCurrentPlayback() ? 'required' : 'optional',
});
return resolved
? {
path: resolved.path,
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
singleResolvedStream: resolved.singleResolvedStream,
}
: null;
},
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
getSubtitleContextLines: (range) =>
collectMediaTimingContextLines({
cues: appState.activeParsedSubtitleCues,
@@ -2927,14 +2904,6 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
endTime: range.endTime,
}),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
}
}
},
showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
});
@@ -4004,7 +3973,6 @@ const {
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop();
@@ -5724,10 +5692,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
const client = appState.mpvClient;
if (!client?.connected) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5747,10 +5711,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5765,10 +5725,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
})
) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5785,10 +5741,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
});
if (!resolvedSource) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5799,10 +5751,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5816,10 +5764,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues,
currentTimeSec,
currentSubtitle,
@@ -5830,10 +5774,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
} catch {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
-60
View File
@@ -828,7 +828,6 @@ test('modal fallback reveal skips showing window when content is not ready', asy
setModalWindowBounds: () => {},
},
{
platform: 'darwin',
scheduleRevealFallback: (callback) => {
scheduledReveal = callback;
return { scheduled: true } as never;
@@ -1364,62 +1363,3 @@ test('modal placement reconcile cancels stale retry ladder after a newer visible
globalThis.clearTimeout = originalClearTimeout;
}
});
test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
const modalWindow = createMockWindow();
const order: string[] = [];
const hideMain = mainWindow.hide;
mainWindow.hide = () => {
order.push('main:hide');
hideMain();
};
const showModal = modalWindow.show;
modalWindow.show = () => {
order.push('modal:show');
showModal();
};
let revealScheduled = false;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => mainWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
platform: 'linux',
scheduleRevealFallback: () => {
revealScheduled = true;
return { scheduled: true } as never;
},
clearRevealFallback: () => {},
},
);
const open = () =>
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'review' },
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
);
assert.equal(open(), true);
assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]);
assert.equal(revealScheduled, false);
assert.equal(modalWindow.getShowCount(), 0);
assert.equal(mainWindow.getHideCount(), 0);
// The open retry must not map the window before the renderer answers either.
assert.equal(open(), true);
assert.equal(modalWindow.getShowCount(), 0);
runtime.notifyOverlayModalOpened('media-timing-review');
assert.deepEqual(order, ['main:hide', 'modal:show']);
assert.equal(mainWindow.isVisible(), false);
assert.equal(modalWindow.isVisible(), true);
assert.equal(modalWindow.ignoreMouseEvents, false);
});
+5 -21
View File
@@ -90,12 +90,6 @@ export function createOverlayModalRuntimeService(
const platform = options.platform ?? process.platform;
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
const reuseModalWindowAfterClose = platform === 'darwin';
// On Linux (Hyprland) every placement dispatch on a mapped window (resize, move, set_prop)
// blanks the still-visible overlay for a few frames while mpv is fullscreen. Revealing the
// dedicated modal window before its renderer has the modal open runs the placement ladder,
// and the open retry, against a visible overlay, which the user sees as flicker. Keep the
// window unmapped until the renderer acknowledges the open, then hide the overlay first.
const deferModalRevealUntilOpened = platform === 'linux';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -463,9 +457,7 @@ export function createOverlayModalRuntimeService(
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
if (!wasVisible) {
if (deferModalRevealUntilOpened) {
// notifyOverlayModalOpened reveals the window once the renderer has the modal open.
} else if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
showModalWindow(modalWindow);
} else {
scheduleModalWindowReveal(modalWindow);
@@ -568,23 +560,15 @@ export function createOverlayModalRuntimeService(
}
const modalWindow = deps.getModalWindow();
const targetIsModalWindow =
modalWindow !== null && !modalWindow.isDestroyed() && targetWindow === modalWindow;
const handOffMainWindowToModal = (): void => {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
};
if (targetIsModalWindow && deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (targetIsModalWindow && !deferModalRevealUntilOpened) {
handOffMainWindowToModal();
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
}
};
@@ -46,13 +46,12 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 36);
assert.equal(calls.length, 35);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -61,7 +60,6 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-remote-media-windows'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -104,7 +102,6 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -32,7 +32,6 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -77,7 +76,6 @@ export function createOnWillQuitCleanupHandler(deps: {
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
};
@@ -75,7 +75,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -158,7 +157,6 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -212,7 +210,6 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -61,7 +61,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -149,7 +148,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
@@ -52,7 +52,6 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
},
+1 -3
View File
@@ -25,9 +25,7 @@ export async function openMediaTimingReviewModal(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{
modal: MODAL,
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
// premature retry re-sends the payload and reloads the waveform for nothing.
timeoutMs: 4_000,
timeoutMs: 1_500,
retryWarning:
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
sendOpen: () =>
+12 -420
View File
@@ -1,15 +1,6 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type {
RemoteMediaWindow,
RemoteMediaWindowRange,
RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
import {
buildMediaTimingReviewPayload,
collectMediaTimingContextLines,
@@ -78,8 +69,6 @@ async function startActiveMediaTimingReview(
options: {
maxMediaDuration?: number;
decisionTimeoutMs?: number;
generateWaveform?: () => Promise<number[]>;
play?: () => Promise<void>;
} = {},
) {
const previewCalls: Array<[number, number]> = [];
@@ -96,16 +85,14 @@ async function startActiveMediaTimingReview(
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: options.generateWaveform ?? (async () => []),
generateWaveform: async () => [],
decisionTimeoutMs: options.decisionTimeoutMs,
createPreviewSession: () => ({
start: async () => undefined,
play: async (startTime, endTime) => {
previewCalls.push([startTime, endTime]);
await options.play?.();
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -149,7 +136,6 @@ test('media timing review pauses playback, resolves exact timing, and restores p
previewCalls.push([startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -191,7 +177,12 @@ test('media timing review pauses playback, resolves exact timing, and restores p
});
test('media timing review analyzes the visible range on the selected audio stream', async () => {
const waveformCalls: SpeechWaveformOptions[] = [];
const waveformCalls: Array<{
mediaPath: string;
startTime: number;
endTime: number;
audioStreamIndex?: number;
}> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
@@ -211,7 +202,6 @@ test('media timing review analyzes the visible range on the selected audio strea
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -249,302 +239,6 @@ test('media timing review analyzes the visible range on the selected audio strea
]);
});
const REMOTE_STREAM_URL = 'https://jellyfin.example/Videos/abc/stream?static=true';
function createWindowStub(options: { fail?: boolean } = {}) {
const calls: Array<{ source: RemoteMediaWindowSource; range: RemoteMediaWindowRange }> = [];
const acquireMediaWindow = async (
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow> => {
calls.push({ source, range });
if (options.fail) throw new Error('offline');
const windowPath = `/tmp/window-${range.startTime}-${range.endTime}.mkv`;
return {
path: windowPath,
startTime: range.startTime,
endTime: range.endTime,
sourcePath: source.path,
audioStreamIndex: source.audioStreamIndex ?? null,
media: {
path: windowPath,
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
},
};
};
return { calls, acquireMediaWindow };
}
function createRemoteReviewRuntime(options: {
windowStub: ReturnType<typeof createWindowStub>;
waveformCalls: SpeechWaveformOptions[];
previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]>;
previewPlays: Array<[string, number, number]>;
disposed: string[];
openModal: (
runtime: ReturnType<typeof createMediaTimingReviewRuntime>,
payload: MediaTimingReviewOpenPayload,
) => Promise<void>;
}) {
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: REMOTE_STREAM_URL,
currentAudioStreamIndex: 2,
requestProperty: async (name) =>
({ pause: true, duration: 100, aid: 3, volume: 60 })[
name as 'pause' | 'duration' | 'aid' | 'volume'
] ?? null,
send: () => undefined,
}),
getCurrentMediaPath: () => REMOTE_STREAM_URL,
getMpvExecutablePath: () => 'mpv',
resolveMediaSource: async () => ({
path: REMOTE_STREAM_URL,
inputOptions: { reconnect: true },
}),
acquireMediaWindow: options.windowStub.acquireMediaWindow,
generateWaveform: async (waveformOptions) => {
options.waveformCalls.push(waveformOptions);
return [0.1, 0.8, 0.2];
},
createPreviewSession: () => {
let mediaPath = '';
return {
start: async (startOptions) => {
mediaPath = startOptions.mediaPath;
options.previewStarts.push(startOptions);
},
play: async (startTime, endTime) => {
options.previewPlays.push([mediaPath, startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
options.disposed.push(mediaPath);
},
};
},
openModal: async (payload) => {
await options.openModal(runtime, payload);
return true;
},
showStatus: () => undefined,
});
return runtime;
}
test('media timing review downloads one window of a remote stream for the waveform and preview', async () => {
const windowStub = createWindowStub();
const waveformCalls: SpeechWaveformOptions[] = [];
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
const previewPlays: Array<[string, number, number]> = [];
const disposed: string[] = [];
const runtime = createRemoteReviewRuntime({
windowStub,
waveformCalls,
previewStarts,
previewPlays,
disposed,
openModal: async (active, payload) => {
const waveform = await active.getWaveform({
reviewId: payload.reviewId,
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
});
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
assert.deepEqual(
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 }),
{ ok: true },
);
active.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
});
},
});
const decision = await runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
assert.deepEqual(windowStub.calls, [
{
source: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true }, audioStreamIndex: 2 },
range: { startTime: 7.5, endTime: 14.5 },
},
]);
assert.deepEqual(waveformCalls, [
{
mediaPath: {
path: '/tmp/window-7.5-14.5.mkv',
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
},
startTime: 7.5,
endTime: 14.5,
},
]);
assert.deepEqual(previewStarts, [
{
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']);
});
test('media timing review restarts the preview on a wider window when the timeline grows', async () => {
const windowStub = createWindowStub();
const waveformCalls: SpeechWaveformOptions[] = [];
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
const previewPlays: Array<[string, number, number]> = [];
const disposed: string[] = [];
const runtime = createRemoteReviewRuntime({
windowStub,
waveformCalls,
previewStarts,
previewPlays,
disposed,
openModal: async (active, payload) => {
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
// The user revealed two more seconds before the clip.
await active.getWaveform({ reviewId: payload.reviewId, startTime: 5.5, endTime: 14.5 });
await active.previewRange({ reviewId: payload.reviewId, startTime: 6, endTime: 12.5 });
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
},
});
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(
windowStub.calls.map((call) => call.range),
[
{ startTime: 7.5, endTime: 14.5 },
{ startTime: 5.5, endTime: 14.5 },
],
);
assert.deepEqual(
previewStarts.map((start) => start.mediaPath),
['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv'],
);
assert.deepEqual(previewPlays, [
['/tmp/window-7.5-14.5.mkv', 9.5, 12.5],
['/tmp/window-5.5-14.5.mkv', 6, 12.5],
]);
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv']);
assert.equal(waveformCalls[0]?.startTime, 5.5);
});
test('media timing review falls back to the remote stream after one failed window download', async () => {
const windowStub = createWindowStub({ fail: true });
const waveformCalls: SpeechWaveformOptions[] = [];
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
const previewPlays: Array<[string, number, number]> = [];
const disposed: string[] = [];
const runtime = createRemoteReviewRuntime({
windowStub,
waveformCalls,
previewStarts,
previewPlays,
disposed,
openModal: async (active, payload) => {
await active.getWaveform({
reviewId: payload.reviewId,
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
});
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
},
});
await runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.equal(windowStub.calls.length, 1);
assert.deepEqual(waveformCalls, [
{
mediaPath: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true } },
startTime: 7.5,
endTime: 14.5,
audioStreamIndex: 2,
},
]);
assert.deepEqual(previewStarts, [
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
]);
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
});
test('media timing review never downloads windows for local media', async () => {
const windowStub = createWindowStub();
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
resolveMediaSource: async () => ({ path: '/video/show.mkv' }),
acquireMediaWindow: windowStub.acquireMediaWindow,
generateWaveform: async () => [0.1, 0.8, 0.2],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 7.5, endTime: 14.5 });
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
return true;
},
showStatus: () => undefined,
});
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.equal(windowStub.calls.length, 0);
});
test('media timing review rejects stale and out-of-range actions before allowing discard', async () => {
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
maxMediaDuration: 3,
@@ -552,14 +246,14 @@ test('media timing review rejects stale and out-of-range actions before allowing
assert.deepEqual(
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
{ ok: false, message: 'This timing review is no longer active.' },
);
assert.deepEqual(
runtime.resolveReview({
reviewId: 'stale-review',
decision: { action: 'confirm', startTime: 10, endTime: 12 },
}),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
{ ok: false, message: 'This timing review is no longer active.' },
);
assert.deepEqual(
runtime.resolveReview({
@@ -652,7 +346,6 @@ test('media timing review does not resume playback when the prior state is unava
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -691,17 +384,11 @@ test('media timing review restores playback when setup fails after pausing', asy
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => {
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => {
throw new Error('preview setup failed');
},
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async () => true,
showStatus: () => undefined,
});
@@ -739,7 +426,6 @@ test('disposing an open review settles it with original timing and restores play
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async () => true,
@@ -763,97 +449,3 @@ test('disposing an open review settles it with original timing and restores play
['set_property', 'pause', 'no'],
]);
});
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
});
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: (listener) => {
playback.ended = listener;
},
dispose: () => undefined,
}),
openModal: async (payload) => {
publishPayload(payload);
return true;
},
onPreviewEnded: (reviewId) => {
endedReviewIds.push(reviewId);
},
showStatus: () => undefined,
});
const pendingDecision = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
const payload = await openedPayload;
assert.deepEqual(
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
{
ok: true,
},
);
assert.ok(playback.ended);
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
});
test('preview reports a stale review when the review ends during playback', async () => {
let endReview: (() => Promise<void>) | null = null;
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
play: async () => {
await endReview?.();
},
});
endReview = () => runtime.dispose();
assert.deepEqual(
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
await pendingDecision;
});
test('waveform reports a stale review when the review ends during analysis', async () => {
let endReview: (() => Promise<void>) | null = null;
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
generateWaveform: async () => {
await endReview?.();
return [0.1, 0.9, 0.2];
},
});
endReview = () => runtime.dispose();
assert.deepEqual(
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
await pendingDecision;
});
+33 -196
View File
@@ -11,13 +11,6 @@ import type {
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import {
isRemoteMediaWindowSourcePath,
type RemoteMediaWindow,
type RemoteMediaWindowRange,
type RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaInput, MediaInputOptions } from '../../media-input';
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
@@ -38,38 +31,19 @@ interface PreviewSession {
executablePath?: string;
audioTrackId?: number;
volume?: number;
absoluteTimestamps?: boolean;
}): 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(). */
onPlaybackEnded(listener: () => void): void;
dispose(): void;
}
interface ReviewMediaSource {
path: string;
inputOptions?: MediaInputOptions;
singleResolvedStream?: boolean;
}
interface ActiveReview {
payload: MediaTimingReviewOpenPayload;
/** What the hidden mpv preview plays when no cached window is available. */
mediaPath: string;
/** What the waveform reads when no cached window is available. */
waveformMedia: MediaInput;
audioStreamIndex?: number;
/** Remote source to download windows of; null for local media or without a cache. */
windowSource: RemoteMediaWindowSource | null;
/** Latest window returned for this review; reused while it still covers the request. */
window: RemoteMediaWindow | null;
windowRequest: (RemoteMediaWindowRange & { promise: Promise<RemoteMediaWindow | null> }) | null;
windowFailed: boolean;
previewOptions: { executablePath?: string; audioTrackId?: number; volume?: number };
preview: { path: string; session: Promise<PreviewSession> } | null;
mpvClient: ReviewMpvClient;
restorePlayback: boolean;
preview: Promise<PreviewSession>;
resolve: (decision: MediaTimingReviewDecision) => void;
}
@@ -79,21 +53,12 @@ export interface MediaTimingReviewRuntimeDeps {
getMpvExecutablePath: () => string;
createPreviewSession: () => PreviewSession;
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
/** Downloads (or reuses) a local window of a remote source covering the range. */
acquireMediaWindow?: (
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
) => Promise<RemoteMediaWindow>;
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
previous: MediaTimingReviewContextLine[];
next: MediaTimingReviewContextLine[];
};
decisionTimeoutMs?: number;
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
}
@@ -159,15 +124,6 @@ export function collectMediaTimingContextLines(options: {
return { previous, next };
}
/**
* Result for requests that name a review main has already resolved or disposed (decision
* watchdog, overlay teardown, duplicate modal). The renderer closes on it instead of
* leaving the user with controls that can never succeed.
*/
function staleReviewResult(): MediaTimingReviewActionResult {
return { ok: false, stale: true, message: 'This timing review is no longer active.' };
}
function isValidMediaTimingRange(
payload: MediaTimingReviewOpenPayload,
startTime: number,
@@ -243,89 +199,6 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
function ensureWindow(
review: ActiveReview,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow | null> {
const { windowSource } = review;
if (!windowSource || review.windowFailed || !deps.acquireMediaWindow) {
return Promise.resolve(null);
}
const coversRange = (candidate: RemoteMediaWindowRange): boolean =>
candidate.startTime <= range.startTime && candidate.endTime >= range.endTime;
if (review.window && coversRange(review.window)) return Promise.resolve(review.window);
const inFlight = review.windowRequest;
if (inFlight && coversRange(inFlight)) return inFlight.promise;
const request = {
startTime: range.startTime,
endTime: range.endTime,
promise: Promise.resolve<RemoteMediaWindow | null>(null),
};
request.promise = deps
.acquireMediaWindow(windowSource, { startTime: range.startTime, endTime: range.endTime })
.then((window) => {
review.window = window;
return window;
})
.catch(() => {
// Fall back to the remote source for the rest of this review instead of retrying.
review.windowFailed = true;
return null;
})
.finally(() => {
if (review.windowRequest === request) review.windowRequest = null;
});
review.windowRequest = request;
return request.promise;
}
/**
* Returns the preview player for the range, restarting it when the range needs a
* different file (the first cached window, or a wider one after the timeline grew).
*/
async function previewFor(
review: ActiveReview,
range: RemoteMediaWindowRange,
): Promise<PreviewSession> {
const window = await ensureWindow(review, range);
if (active !== review) {
// The review ended during the download; do not start a player nobody will dispose.
throw new Error('This timing review is no longer active.');
}
const mediaPath = window?.path ?? review.mediaPath;
if (review.preview?.path === mediaPath) return review.preview.session;
const previous = review.preview;
const session = deps.createPreviewSession();
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
const { audioTrackId, ...previewOptions } = review.previewOptions;
const started = session
.start({
mediaPath,
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
...(window
? { absoluteTimestamps: true }
: audioTrackId !== undefined
? { audioTrackId }
: {}),
})
.then(() => session)
.catch((error) => {
session.dispose();
throw error;
});
review.preview = { path: mediaPath, session: started };
void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started;
}
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
const mpvClient = deps.getMpvClient();
const mediaPath =
@@ -335,12 +208,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw] = await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
]);
const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
@@ -360,51 +232,38 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
mediaDuration: finiteNumber(durationRaw) ?? undefined,
...(contextLines ? { contextLines } : {}),
});
const sourcePath = resolvedSource?.path.trim() || mediaPath;
const inputOptions = resolvedSource?.inputOptions;
const audioStreamIndex =
resolvedSource?.singleResolvedStream || mpvClient.currentAudioStreamIndex == null
? undefined
: mpvClient.currentAudioStreamIndex;
const windowSource: RemoteMediaWindowSource | null =
deps.acquireMediaWindow && isRemoteMediaWindowSourcePath(sourcePath)
? {
path: sourcePath,
...(inputOptions ? { inputOptions } : {}),
audioStreamIndex: audioStreamIndex ?? null,
}
: null;
const previewSession = deps.createPreviewSession();
const preview = previewSession
.start({
mediaPath,
executablePath: deps.getMpvExecutablePath(),
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
volume: finiteNumber(volumeRaw) ?? undefined,
})
.then(() => previewSession)
.catch((error) => {
previewSession.dispose();
throw error;
});
void preview.catch(() => {});
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
resolveDecision = resolve;
});
const review: ActiveReview = {
active = {
payload,
mediaPath,
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
windowSource,
window: null,
windowRequest: null,
windowFailed: false,
previewOptions: {
executablePath: deps.getMpvExecutablePath(),
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
volume: finiteNumber(volumeRaw) ?? undefined,
},
preview: null,
...(mpvClient.currentAudioStreamIndex !== null &&
mpvClient.currentAudioStreamIndex !== undefined
? { audioStreamIndex: mpvClient.currentAudioStreamIndex }
: {}),
mpvClient,
restorePlayback: pendingPauseRestore === mpvClient,
preview,
resolve: resolveDecision,
};
active = review;
pendingPauseRestore = null;
// Download the visible timeline once now; the waveform and preview both wait on it.
void previewFor(review, {
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
}).catch(() => {});
const opened = await deps.openModal(payload).catch(() => false);
if (!opened) {
@@ -452,27 +311,16 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
return { ok: false, message: 'This timing review is no longer active.' };
}
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
return { ok: false, message: 'The selected preview range is invalid.' };
}
try {
const previewSession = await previewFor(current, request);
if (active !== current) {
return staleReviewResult();
}
const previewSession = await current.preview;
await previewSession.play(request.startTime, request.endTime);
// Playback spans the whole clip, so the review can end (watchdog, teardown) while
// it runs; reporting success would leave the modal open on a dead review.
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
@@ -485,7 +333,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
): Promise<MediaTimingReviewWaveformResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
return { ok: false, message: 'This timing review is no longer active.' };
}
if (
!Number.isFinite(request.startTime) ||
@@ -499,30 +347,19 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
try {
const window = await ensureWindow(current, request);
if (active !== current) {
return staleReviewResult();
}
const peaks = await deps.generateWaveform({
mediaPath: window?.media ?? current.waveformMedia,
mediaPath: current.mediaPath,
startTime: request.startTime,
endTime: request.endTime,
...(!window && current.audioStreamIndex !== undefined
...(current.audioStreamIndex !== undefined
? { audioStreamIndex: current.audioStreamIndex }
: {}),
});
// ffmpeg decoding runs long enough for the review to end underneath it.
if (active !== current) {
return staleReviewResult();
}
if (peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
if (active !== current || peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return { ok: true, peaks };
} catch {
if (active !== current) {
return staleReviewResult();
}
return { ok: false, message: 'Timing waveform is unavailable.' };
}
}
@@ -530,11 +367,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || reviewId !== current.payload.reviewId) {
return staleReviewResult();
return { ok: false, message: 'This timing review is no longer active.' };
}
try {
const previewSession = current.preview ? await current.preview.session : null;
await previewSession?.stop();
const previewSession = await current.preview;
await previewSession.stop();
return { ok: true };
} catch (error) {
return {
@@ -547,7 +384,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
return { ok: false, message: 'This timing review is no longer active.' };
}
if (request.decision.action === 'confirm') {
const { startTime, endTime, text } = request.decision;
@@ -566,7 +403,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const current = active;
active = null;
if (!current) return;
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
void current.preview.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
@@ -3,7 +3,6 @@ import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
@@ -703,37 +702,3 @@ test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue
'(東)≪好きだと\n自覚してしまったものの➡',
);
});
test('resolvePrimarySubtitle drops a finished caption row lingering beside a fresh line', () => {
// Broadcast captions give each row its own event, and a row of the previous line can
// outlive its siblings by a frame. mpv's sub-text still lists it, so the mined line
// must come from the parsed cue that is actually running, with that cue's timings.
const ass = [
'[Script Info]',
'PlayResY: 540',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:14:30.00,0:14:33.00,Default,,0,0,0,,{\\pos(232,437)\\fscx50}{\\fscx100}東{\\fscx50}{\\fscx100}ずっと 言えなかっ',
'Dialogue: 0,0:14:30.00,0:14:33.02,Default,,0,0,0,,{\\pos(232,497)}たが',
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,437)}⸨もし お互い',
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,497)}本命 受かったら 大学 近いし➡',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
const resolved = resolvePrimarySubtitle({
liveText: 'たが\n⸨もし お互い\n本命 受かったら 大学 近いし➡',
currentTimeSec: 14 * 60 + 33.05,
cues,
});
assert.deepEqual(
{ ...resolved, cues: resolved?.cues.map((cue) => cue.text) },
{
text: '⸨もし お互い\n本命 受かったら 大学 近いし➡',
startTime: 14 * 60 + 33,
endTime: 14 * 60 + 36,
cues: ['⸨もし お互い\n本命 受かったら 大学 近いし➡'],
},
);
});
+9 -21
View File
@@ -319,26 +319,6 @@ export function resolveRecordedPrimarySubtitleText(options: {
);
}
/**
* The parsed view of the live text with its cue timings: a canonical animation when one
* explains the live lines, otherwise the active parsed cues. Null when the parsed cues
* cannot account for every live line, in which case callers keep the raw mpv text.
*/
export function resolvePrimarySubtitle(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
const liveText = decodedLiveText(options.liveText, options.cues);
if (!liveText.trim()) {
return null;
}
return (
resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
resolveActiveParsedPrimarySubtitle({ ...options, liveText })
);
}
export function resolvePrimarySubtitleText(options: {
liveText: string;
currentTimeSec: number;
@@ -348,5 +328,13 @@ export function resolvePrimarySubtitleText(options: {
if (!liveText.trim()) {
return liveText;
}
return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
return (
resolveCanonicalPrimarySubtitle({
liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ??
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
removeLiveGlyphFragmentLines(liveText)
);
}
+30 -140
View File
@@ -10,9 +10,6 @@ import {
MediaGenerator,
type MediaGeneratorOptions,
} from './media-generator';
import { RemoteMediaWindowCache } from './core/services/remote-media-window-cache';
const REMOTE_STREAM_URL = 'https://jellyfin.example/Videos/abc/stream?static=true';
async function withStubbedFfmpeg(
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
@@ -24,7 +21,6 @@ async function withStubbedFfmpeg(
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
const binDir = path.join(root, 'bin');
const tempDir = path.join(root, 'media');
const windowsDir = path.join(root, 'windows');
const argsPath = path.join(root, 'ffmpeg-args.txt');
fs.mkdirSync(binDir, { recursive: true });
const ffmpegStubPath = path.join(binDir, 'ffmpeg-stub.cjs');
@@ -38,7 +34,7 @@ async function withStubbedFfmpeg(
" console.log(' V..... libaom-av1');",
' process.exit(0);',
'}',
"fs.appendFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args) + '\\n', 'utf8');",
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');",
'const outputPath = args.at(-1);',
"if (process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT !== '1') {",
" fs.writeFileSync(outputPath, 'avif', 'utf8');",
@@ -65,18 +61,12 @@ async function withStubbedFfmpeg(
} else {
delete process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT;
}
// Each test gets its own window cache so remote inputs never leak windows between tests.
const remoteMediaWindows = new RemoteMediaWindowCache({ tempDir: windowsDir, idleTtlMs: 0 });
const generator = new MediaGenerator(tempDir, {
remoteMediaWindows,
...options,
});
const generator = new MediaGenerator(tempDir, options);
try {
await run(generator, argsPath);
} finally {
generator.cleanup();
remoteMediaWindows.cleanup();
process.env.PATH = originalPath;
if (originalArgsPath === undefined) {
delete process.env.SUBMINER_TEST_FFMPEG_ARGS;
@@ -92,17 +82,8 @@ async function withStubbedFfmpeg(
}
}
function readAllFfmpegArgs(argsPath: string): string[][] {
return fs
.readFileSync(argsPath, 'utf8')
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as string[]);
}
/** Arguments of the most recent ffmpeg invocation. */
function readFfmpegArgs(argsPath: string): string[] {
return readAllFfmpegArgs(argsPath).at(-1) ?? [];
return JSON.parse(fs.readFileSync(argsPath, 'utf8')) as string[];
}
test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => {
@@ -291,131 +272,41 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as
});
test('generateAudio adds remote input options before the ffmpeg input', async () => {
await withStubbedFfmpeg(
async (generator, argsPath) => {
await generator.generateAudio(
{
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
inputOptions: {
reconnect: true,
userAgent: 'Mozilla/5.0',
headers: {
Referer: 'https://www.youtube.com/',
Origin: 'https://www.youtube.com',
},
},
},
10,
12,
);
const args = readFfmpegArgs(argsPath);
const inputIndex = args.indexOf('-i');
assert.ok(inputIndex > 0);
assert.ok(args.indexOf('-reconnect') > -1);
assert.ok(args.indexOf('-reconnect') < inputIndex);
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_on_network_error') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
assert.equal(
args[args.indexOf('-headers') + 1],
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
);
},
{ remoteMediaWindows: null },
);
});
test('generateAudio downloads a remote window once and extracts from it with absolute seeks', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio(
{ path: REMOTE_STREAM_URL, inputOptions: { reconnect: true } },
{
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
inputOptions: {
reconnect: true,
userAgent: 'Mozilla/5.0',
headers: {
Referer: 'https://www.youtube.com/',
Origin: 'https://www.youtube.com',
},
},
},
10,
12,
0.5,
2,
);
const calls = readAllFfmpegArgs(argsPath);
assert.equal(calls.length, 2);
const [fetchArgs, audioArgs] = calls as [string[], string[]];
assert.equal(fetchArgs[fetchArgs.indexOf('-i') + 1], REMOTE_STREAM_URL);
assert.ok(fetchArgs.indexOf('-reconnect') < fetchArgs.indexOf('-i'));
assert.equal(fetchArgs[fetchArgs.indexOf('-ss') + 1], '9.25');
assert.equal(fetchArgs[fetchArgs.lastIndexOf('-map') + 1], '0:2');
assert.ok(fetchArgs.includes('-copyts'));
const windowPath = audioArgs[audioArgs.indexOf('-i') + 1];
assert.ok(windowPath?.endsWith('.mkv'));
assert.notEqual(windowPath, REMOTE_STREAM_URL);
assert.equal(audioArgs[audioArgs.indexOf('-ss') + 1], '9.5');
assert.equal(audioArgs[audioArgs.indexOf('-seek_timestamp') + 1], '1');
assert.ok(audioArgs.indexOf('-seek_timestamp') < audioArgs.indexOf('-i'));
assert.equal(audioArgs.includes('-reconnect'), false);
assert.equal(audioArgs.includes('-map'), false);
assert.equal(audioArgs.includes('-probesize'), false);
assert.ok(audioArgs.includes('loudnorm=I=-23:TP=-2:LRA=11'));
});
});
test('generateScreenshot reuses a downloaded window but never downloads one itself', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateScreenshot(REMOTE_STREAM_URL, 11, { format: 'jpg' });
let calls = readAllFfmpegArgs(argsPath);
assert.equal(calls.length, 1);
assert.equal(calls[0]![calls[0]!.indexOf('-i') + 1], REMOTE_STREAM_URL);
await generator.generateAudio(REMOTE_STREAM_URL, 10, 12);
await generator.generateScreenshot(REMOTE_STREAM_URL, 11, { format: 'jpg' });
await generator.generateScreenshot(REMOTE_STREAM_URL, 40, { format: 'jpg' });
calls = readAllFfmpegArgs(argsPath);
assert.equal(calls.length, 5);
const insideWindow = calls[3]!;
assert.ok(insideWindow[insideWindow.indexOf('-i') + 1]?.endsWith('.mkv'));
assert.equal(insideWindow[insideWindow.indexOf('-seek_timestamp') + 1], '1');
const outsideWindow = calls[4]!;
assert.equal(outsideWindow[outsideWindow.indexOf('-i') + 1], REMOTE_STREAM_URL);
});
});
test('generateAnimatedImage downloads the clip window before encoding', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAnimatedImage(REMOTE_STREAM_URL, 10, 12, 0, { fps: 10 });
const calls = readAllFfmpegArgs(argsPath).filter(
(args) => args[0] !== '-hide_banner' || args[1] !== '-encoders',
const args = readFfmpegArgs(argsPath);
const inputIndex = args.indexOf('-i');
assert.ok(inputIndex > 0);
assert.ok(args.indexOf('-reconnect') > -1);
assert.ok(args.indexOf('-reconnect') < inputIndex);
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_on_network_error') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
assert.equal(
args[args.indexOf('-headers') + 1],
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
);
assert.equal(calls.length, 2);
assert.equal(calls[0]![calls[0]!.indexOf('-i') + 1], REMOTE_STREAM_URL);
assert.ok(calls[1]![calls[1]!.indexOf('-i') + 1]?.endsWith('.mkv'));
assert.equal(calls[1]![calls[1]!.indexOf('-seek_timestamp') + 1], '1');
});
});
test('generateAudio reads the remote source directly when the window download fails', async () => {
await withStubbedFfmpeg(
async (generator, argsPath) => {
await generator.generateAudio(REMOTE_STREAM_URL, 10, 12);
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-i') + 1], REMOTE_STREAM_URL);
assert.equal(args.includes('-seek_timestamp'), false);
},
{
remoteMediaWindows: new RemoteMediaWindowCache({
execFile: (_file, _args, _options, callback) =>
queueMicrotask(() => callback(Object.assign(new Error('offline'), { code: 1 }))),
idleTtlMs: 0,
logDebug: () => undefined,
}),
},
);
});
test('generateAudio skips stale audio stream maps for single resolved streams', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio(
@@ -429,9 +320,8 @@ test('generateAudio skips stale audio stream maps for single resolved streams',
22,
);
const [fetchArgs, audioArgs] = readAllFfmpegArgs(argsPath) as [string[], string[]];
assert.equal(fetchArgs[fetchArgs.lastIndexOf('-map') + 1], '0:a');
assert.equal(audioArgs.includes('-map'), false);
const args = readFfmpegArgs(argsPath);
assert.equal(args.includes('-map'), false);
});
});
+3 -84
View File
@@ -22,12 +22,6 @@ import * as path from 'path';
import * as os from 'os';
import { createLogger } from './logger';
import { normalizeMediaInput, type MediaInput } from './media-input';
import {
getSharedRemoteMediaWindowCache,
isRemoteMediaWindowSourcePath,
type RemoteMediaWindowCache,
type RemoteMediaWindowRange,
} from './core/services/remote-media-window-cache';
const log = createLogger('media');
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
@@ -92,11 +86,6 @@ export interface MediaGeneratorOptions {
logDebug?: (message: string) => void;
now?: () => number;
execFile?: MediaGeneratorExecFile;
/**
* Local window cache for http(s) sources. Defaults to the process-wide cache shared
* with the timing review; pass `null` to always read remote sources directly.
*/
remoteMediaWindows?: RemoteMediaWindowCache | null;
}
function sanitizeDebugToken(value: string, fallback: string): string {
@@ -243,54 +232,6 @@ export class MediaGenerator {
}, delayMs);
}
/**
* Swaps an http(s) input for the locally cached window that covers `range`, so the
* clip is downloaded once instead of per FFmpeg run. `acquire` downloads on a miss;
* `lookup` only reuses a window that another step already fetched. Any failure falls
* back to reading the remote source directly.
*/
private async resolveRemoteWindowInput(
input: MediaInput,
range: RemoteMediaWindowRange,
audioStreamIndex: number | null | undefined,
mode: 'acquire' | 'lookup',
): Promise<MediaInput> {
const cache =
this.options.remoteMediaWindows === undefined
? getSharedRemoteMediaWindowCache()
: this.options.remoteMediaWindows;
const sourcePath = typeof input === 'string' ? input : input.path;
if (!cache || !isRemoteMediaWindowSourcePath(sourcePath)) {
return input;
}
const source = {
path: sourcePath,
...(typeof input === 'object' && input.inputOptions
? { inputOptions: input.inputOptions }
: {}),
audioStreamIndex:
typeof input === 'object' && input.singleResolvedStream ? null : (audioStreamIndex ?? null),
};
const description = describeMediaInputForDebugLog(input);
try {
const window =
mode === 'acquire' ? await cache.acquire(source, range) : await cache.lookup(source, range);
if (!window) {
this.logMediaDebug(`window miss ${description} mode=${mode}`);
return input;
}
this.logMediaDebug(
`window hit ${description} mode=${mode} start=${window.startTime} end=${window.endTime}`,
);
return window.media;
} catch (error) {
this.logMediaDebug(
`window failed ${description} mode=${mode} reason=${sanitizeDebugToken((error as Error).message, 'error')}`,
);
return input;
}
}
private ffmpegError(label: string, error: ExecFileException): Error {
if (error.code === 'ENOENT') {
return new Error('FFmpeg not found. Install FFmpeg to enable media generation.');
@@ -340,13 +281,7 @@ export class MediaGenerator {
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime - safePadding);
const duration = endTime - start + safePadding;
const sourceInput = await this.resolveRemoteWindowInput(
videoPath,
{ startTime: start, endTime: start + duration },
audioStreamIndex,
'acquire',
);
const mediaInput = normalizeMediaInput(sourceInput);
const mediaInput = normalizeMediaInput(videoPath);
const inputDescription = describeMediaInputForDebugLog(videoPath);
const hasSelectedAudioStream =
!mediaInput.singleResolvedStream &&
@@ -450,15 +385,7 @@ export class MediaGenerator {
png: 'png',
webp: 'webp',
};
// A single frame is cheap to fetch remotely, so only reuse a window another step downloaded.
const mediaInput = normalizeMediaInput(
await this.resolveRemoteWindowInput(
videoPath,
{ startTime: timestamp, endTime: timestamp },
null,
'lookup',
),
);
const mediaInput = normalizeMediaInput(videoPath);
const inputDescription = describeMediaInputForDebugLog(videoPath);
const args: string[] = [
@@ -606,17 +533,9 @@ export class MediaGenerator {
);
}
const mediaInput = normalizeMediaInput(
await this.resolveRemoteWindowInput(
videoPath,
{ startTime: start, endTime: start + duration },
null,
'acquire',
),
);
return new Promise((resolve, reject) => {
const outputPath = this.createTempOutputPath('animation', 'avif');
const mediaInput = normalizeMediaInput(videoPath);
const startedAt = this.nowMs();
const encoderArgs: string[] = ['-c:v', av1Encoder];
-10
View File
@@ -11,12 +11,6 @@ export type MediaInput =
source?: string;
inputOptions?: MediaInputOptions;
singleResolvedStream?: boolean;
/**
* The file keeps the original media timestamps instead of starting at zero (a
* stream-copied window of a longer source). Seek with `-ss` against those
* absolute timestamps rather than relative to the file's own start time.
*/
absoluteTimestamps?: boolean;
};
export type NormalizedMediaInput = {
@@ -95,10 +89,6 @@ export function normalizeMediaInput(input: MediaInput): NormalizedMediaInput {
inputArgs.push('-headers', headers);
}
if (input.absoluteTimestamps) {
inputArgs.push('-seek_timestamp', '1');
}
return {
path: input.path,
inputArgs,
-46
View File
@@ -1,46 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { runInNewContext } from 'node:vm';
import { build } from 'esbuild';
test('sidebar clipboard bridge writes exact text without renderer focus and rejects non-text input', async () => {
const result = await build({
entryPoints: ['src/preload.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
external: ['electron'],
write: false,
});
const output = result.outputFiles[0];
assert.ok(output);
const writes: string[] = [];
let exposed: unknown;
runInNewContext(output.text, {
process: { argv: [] },
require: (name: string) => {
assert.equal(name, 'electron');
return {
ipcRenderer: { on: () => {} },
clipboard: { writeText: (text: string) => writes.push(text) },
contextBridge: {
exposeInMainWorld: (_name: string, api: unknown) => {
exposed = api;
},
},
};
},
});
assert.ok(
typeof exposed === 'object' && exposed !== null && 'copySubtitleSidebarSelection' in exposed,
);
const copy = exposed.copySubtitleSidebarSelection;
if (typeof copy !== 'function') throw new Error('Missing clipboard bridge');
const text = '最初の台詞\n二行目\n\n同じ台詞';
await copy(text);
assert.deepEqual(writes, [text]);
for (const value of [null, undefined, 42, { text }, ['台詞']]) {
await assert.rejects(async () => copy(value), /Subtitle selection must be text/);
}
assert.deepEqual(writes, [text]);
});
+1 -10
View File
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
import { resolveOverlayLayerFromArgv } from './preload-args';
import type {
SubtitleData,
@@ -191,10 +191,6 @@ const onOpenMediaTimingReviewEvent =
IPC_CHANNELS.event.mediaTimingReviewOpen,
(payload) => payload as MediaTimingReviewOpenPayload,
);
const onMediaTimingReviewPreviewEndedEvent = createQueuedIpcListenerWithPayload<string>(
IPC_CHANNELS.event.mediaTimingReviewPreviewEnded,
(payload) => (typeof payload === 'string' ? payload : ''),
);
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel,
@@ -301,10 +297,6 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
copySubtitleSidebarSelection: async (text: unknown) => {
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
clipboard.writeText(text);
},
getPlaybackPaused: (): Promise<boolean | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
onSubtitleAss: (callback: (assText: string) => void) => {
@@ -477,7 +469,6 @@ const electronAPI: ElectronAPI = {
onOpenTsukihime: onOpenTsukihimeEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent,
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> =>
+1 -6
View File
@@ -24,12 +24,7 @@ test('quality gate checkout does not persist GitHub credentials', () => {
test('quality gate installs Lua and runs the environment suite before coverage', () => {
assert.match(qualityGateWorkflow, /name: Install Lua/);
assert.match(
qualityGateWorkflow,
/apt_sources=\(-o Dir::Etc::sourcelist=sources\.list\.d\/ubuntu\.sources -o Dir::Etc::sourceparts=-\)/,
);
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+update/);
assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+install\s+-y\s+lua5\.4/);
assert.match(qualityGateWorkflow, /apt-get install -y lua5\.4/);
assert.match(
qualityGateWorkflow,
/Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/,
-23
View File
@@ -1863,29 +1863,6 @@ test('keyboard mode: popup hidden after mode off clears stale selected token hig
}
});
test('Yomitan popup dismissal and subtitle updates preserve selection outside the overlay subtitle', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
let cleared = false;
try {
Object.defineProperty(window, 'getSelection', {
configurable: true,
value: () => ({
anchorNode: {},
removeAllRanges: () => {
cleared = true;
},
}),
});
Object.assign(ctx.dom.subtitleRoot, { contains: () => false });
await handlers.setupMpvInputForwarding();
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
handlers.syncKeyboardTokenSelection();
assert.equal(cleared, false);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+1 -4
View File
@@ -436,10 +436,7 @@ export function createKeyboardHandlers(
}
function clearNativeSubtitleSelection(): void {
const selection = window.getSelection();
if (!selection?.anchorNode || ctx.dom.subtitleRoot.contains(selection.anchorNode)) {
selection?.removeAllRanges();
}
window.getSelection()?.removeAllRanges();
ctx.dom.subtitleRoot.classList.remove('has-selection');
}
-1
View File
@@ -731,7 +731,6 @@
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
<div class="modal-header">
<div class="modal-title">Subtitle Sidebar</div>
<button id="subtitleSidebarCopy" class="modal-close" type="button" hidden>Copy</button>
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body subtitle-sidebar-body">
@@ -8,7 +8,6 @@ import {
formatMediaTimingTimestamp,
mediaTimingTimeFromPointer,
slideMediaTimingSelection,
trimMediaTimingSelectionEnd,
} from './media-timing-review';
test('waveform path mirrors normalized peaks around its center line', () => {
@@ -123,35 +122,3 @@ test('preview request guard blocks overlap and invalidates stale responses', ()
guard.finish(second!);
assert.equal(guard.isInFlight(), false);
});
test('trailing silence trim follows the last speech slice and keeps cut-off or silent lines', () => {
// 10 slices over a 10 s timeline: one slice per second, speech in seconds 2-4 only.
const peaks = [0, 0, 0.9, 0.8, 0.7, 0.1, 0.2, 0, 0, 0];
const base = { peaks, timelineStart: 0, timelineEnd: 10, lineStart: 2, lineEnd: 8 };
const trimmed = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8, endPadSeconds: 0 });
assert.ok(trimmed !== null && Math.abs(trimmed - 5.15) < 1e-9);
const padded = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8.5, endPadSeconds: 0.5 });
assert.ok(padded !== null && Math.abs(padded - 5.65) < 1e-9);
// Speech running through the line end means the subtitle cuts the audio off; keep it.
assert.equal(
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5, selectionEnd: 5, endPadSeconds: 0 }),
null,
);
// No speech inside the line at all: keep the subtitle timing rather than guess.
assert.equal(
trimMediaTimingSelectionEnd({ ...base, lineStart: 6, selectionEnd: 8, endPadSeconds: 0 }),
null,
);
// A saving below the minimum trim is not worth moving the handle for.
assert.equal(
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5.2, selectionEnd: 5.2, endPadSeconds: 0 }),
null,
);
assert.equal(
trimMediaTimingSelectionEnd({ ...base, peaks: [1], selectionEnd: 8, endPadSeconds: 0 }),
null,
);
});
+3 -105
View File
@@ -11,16 +11,6 @@ const FINE_ADJUST_SECONDS = 0.1;
const COARSE_ADJUST_SECONDS = 0.5;
const TIMELINE_EXPANSION_SECONDS = 2;
const LINE_REVEAL_MARGIN_SECONDS = 1;
/**
* Subtitles usually linger past the dialogue for readability, so an untouched clip end
* follows the last speech-weighted waveform slice above this level (0..1, relative to the
* clip's own noise floor) plus a short tail, when that saves at least the minimum trim.
*/
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;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -115,48 +105,6 @@ export function buildMediaTimingWaveformPath(peaks: number[]): string {
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
}
/**
* Where an untouched clip should end once the waveform is known: just after the line's
* last speech slice, plus the configured end padding. Null keeps the subtitle timing when
* no speech shows inside the line, when speech runs through the line end (the subtitle is
* cutting the audio off, not lingering), or when the saving is too small to matter.
*/
export function trimMediaTimingSelectionEnd(options: {
peaks: readonly number[];
timelineStart: number;
timelineEnd: number;
lineStart: number;
lineEnd: number;
selectionEnd: number;
endPadSeconds: number;
}): number | null {
const pointCount = options.peaks.length;
const span = options.timelineEnd - options.timelineStart;
if (pointCount < 2 || span <= 0 || options.lineEnd <= options.lineStart) return null;
// Point i covers [i, i + 1) / pointCount of the timeline (see computeWaveformPeaks).
const sliceEnd = (index: number): number =>
options.timelineStart + ((index + 1) / pointCount) * span;
const firstIndex = Math.max(
0,
Math.floor(((options.lineStart - options.timelineStart) / span) * pointCount),
);
const lastIndex = Math.min(
pointCount - 1,
Math.ceil(((options.lineEnd - options.timelineStart) / span) * pointCount) - 1,
);
let lastSpeechIndex = -1;
for (let index = lastIndex; index >= firstIndex; index -= 1) {
if ((options.peaks[index] ?? 0) >= SPEECH_LEVEL_THRESHOLD) {
lastSpeechIndex = index;
break;
}
}
if (lastSpeechIndex === -1 || lastSpeechIndex >= lastIndex) return null;
const trimmedEnd = sliceEnd(lastSpeechIndex) + SPEECH_TAIL_SECONDS + options.endPadSeconds;
if (options.selectionEnd - trimmedEnd < MINIMUM_TRAILING_TRIM_SECONDS) return null;
return trimmedEnd;
}
export function constrainMediaTimingSelection(options: {
nextStart: number;
nextEnd: number;
@@ -226,8 +174,6 @@ export function createMediaTimingReviewModal(
let nextCount = 0;
let startPadSeconds = 0;
let endPadSeconds = 0;
/** True until the user moves the clip; the first waveform then trims trailing silence. */
let trailingTrimPending = false;
let resolveInFlight = false;
let previewPlaying = false;
let previewTimer: ReturnType<typeof setTimeout> | null = null;
@@ -263,11 +209,7 @@ 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.
*/
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
function setPreviewPlaying(playing: boolean): void {
previewPlaying = playing;
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
@@ -280,14 +222,7 @@ export function createMediaTimingReviewModal(
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
void track.offsetWidth;
track.classList.add('is-previewing');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
}
/** 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);
setStatus('');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
}
/** Callers that need to report a failure set their own status after stopping the preview. */
@@ -387,10 +322,6 @@ export function createMediaTimingReviewModal(
) {
return;
}
if (!result.ok && result.stale) {
closeResolvedReview();
return;
}
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
if (!path) {
setWaveformState('unavailable');
@@ -398,7 +329,6 @@ export function createMediaTimingReviewModal(
}
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
setWaveformState('ready');
trimTrailingSilence(result.peaks ?? []);
} catch {
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
setWaveformState('unavailable');
@@ -406,29 +336,6 @@ export function createMediaTimingReviewModal(
}
}
/**
* Moves an untouched clip end back to where the line's dialogue ends. The Line end
* rail keeps marking the subtitle timing, and Reset restores it.
*/
function trimTrailingSilence(peaks: readonly number[]): void {
if (!payload || !trailingTrimPending || previewPlaying || previewRequest.isInFlight()) {
return;
}
const lineRange = currentLineSelection();
const trimmedEnd = trimMediaTimingSelectionEnd({
peaks,
timelineStart,
timelineEnd,
lineStart: lineRange.rangeStart,
lineEnd: lineRange.rangeEnd,
selectionEnd,
endPadSeconds,
});
if (trimmedEnd === null) return;
updateSelection(selectionStart, trimmedEnd);
setStatus('Clip end moved to where the dialogue ends. Reset restores the subtitle timing.');
}
function queueWaveformLoad(delayMs = 0): void {
if (waveformTimer !== null) clearTimeout(waveformTimer);
waveformSequence += 1;
@@ -536,7 +443,6 @@ export function createMediaTimingReviewModal(
});
selectionStart = nextSelection.start;
selectionEnd = nextSelection.end;
trailingTrimPending = false;
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
setStatus('');
renderSelection();
@@ -711,13 +617,11 @@ export function createMediaTimingReviewModal(
reviewId: payload.reviewId,
decision,
});
if (!result.ok && !result.stale) {
if (!result.ok) {
setStatus(result.message ?? 'The timing review could not be resolved.', true);
showEditor();
return;
}
// A stale review was already settled by main; keeping the modal up would leave
// controls that can never succeed over a live mpv window.
closeResolvedReview();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
@@ -765,10 +669,6 @@ export function createMediaTimingReviewModal(
}
return;
}
if (!result.ok && result.stale) {
closeResolvedReview();
return;
}
if (!result.ok) {
setStatus(result.message ?? 'Audio preview is unavailable.', true);
setPreviewPlaying(false);
@@ -804,7 +704,6 @@ export function createMediaTimingReviewModal(
nextCount = 0;
startPadSeconds = Math.max(0, nextPayload.originalStartTime - nextPayload.selectionStartTime);
endPadSeconds = Math.max(0, nextPayload.selectionEndTime - nextPayload.originalEndTime);
trailingTrimPending = true;
resolveInFlight = false;
setPreviewPlaying(false);
ctx.dom.mediaTimingReviewKind.textContent =
@@ -952,7 +851,6 @@ export function createMediaTimingReviewModal(
return {
openMediaTimingReviewModal,
handlePreviewEnded,
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
@@ -1,126 +0,0 @@
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle';
import { createRendererState } from '../state';
import { resolveRendererDom } from '../utils/dom';
import { resolvePlatformInfo } from '../utils/platform';
import { createSubtitleSidebarModal } from './subtitle-sidebar';
import {
getSubtitleSidebarSelection,
wireSubtitleSidebarSelection,
} from './subtitle-sidebar-selection';
export async function setup() {
const commands: unknown[] = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'episode-1:track-1',
cues: [
{ text: '最初の台詞', startTime: 0, endTime: 1 },
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
{ text: '同じ台詞', startTime: 2, endTime: 3 },
...Array.from({ length: 30 }, (_, i) => ({
text: `後の台詞${i}`,
startTime: i + 3,
endTime: i + 4,
})),
],
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
currentTimeSec: 0,
config: {
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
enabled: true,
layout: 'overlay',
pauseVideoOnHover: false,
autoScroll: true,
css: {},
},
};
Object.defineProperty(window, 'electronAPI', {
value: {
getSubtitleSidebarSnapshot: async () => snapshot,
copySubtitleSidebarSelection: async (text) => {
if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function')
throw new Error('Missing test clipboard bridge');
window.copyTestSelection(text);
},
getOverlayLayer: () => 'visible',
sendMpvCommand: (command) => {
commands.push(command);
},
setIgnoreMouseEvents: () => {},
} satisfies Pick<
ElectronAPI,
| 'getSubtitleSidebarSnapshot'
| 'copySubtitleSidebarSelection'
| 'getOverlayLayer'
| 'sendMpvCommand'
| 'setIgnoreMouseEvents'
>,
});
const ctx = {
dom: resolveRendererDom(),
state: createRendererState(),
platform: resolvePlatformInfo(),
};
const modal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
wireSubtitleSidebarSelection(ctx);
await modal.openSubtitleSidebarModal();
const list = ctx.dom.subtitleSidebarList;
list.style.height = '180px';
list.style.overflowY = 'auto';
const selection = window.getSelection();
if (!selection) throw new Error('Native selection unavailable');
const textNode = (index: number) => {
const node = list.children[index]?.querySelector('.subtitle-sidebar-text')?.firstChild;
if (!node) throw new Error(`Missing cue ${index}`);
return node;
};
const select = (backward = false) => {
const start = textNode(0);
const end = textNode(2);
selection.setBaseAndExtent(backward ? end : start, 2, backward ? start : end, 2);
document.dispatchEvent(new Event('selectionchange'));
return getSubtitleSidebarSelection(list);
};
// This is the same competing action as the renderer's current-subtitle shortcut.
let fallbackCopies = 0;
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1;
});
return {
select,
selected: () => getSubtitleSidebarSelection(list),
buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden,
fallbackCopies: () => fallbackCopies,
dragPoints: () =>
[0, 2].map((index) => {
const range = document.createRange();
range.setStart(textNode(index), 2);
range.collapse(true);
const rect = range.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y + rect.height / 2) };
}),
clickCopy: () => ctx.dom.subtitleSidebarCopy.click(),
clickCue: () => {
const before = commands.length;
list.children[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
return commands.length - before;
},
updatePlayback: async () => {
list.scrollTop = 0;
snapshot.currentTimeSec = 25;
snapshot.currentSubtitle = { text: '後の台詞22', startTime: 25, endTime: 26 };
await modal.refreshSubtitleSidebarSnapshot();
return list.scrollTop;
},
changeSource: async () => {
snapshot.sourceKey = 'episode-2:track-1';
await modal.refreshSubtitleSidebarSnapshot();
return getSubtitleSidebarSelection(list);
},
clear: () => selection.removeAllRanges(),
close: () => modal.closeSubtitleSidebarModal(),
};
}
@@ -1,130 +0,0 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import test from 'node:test';
import { build } from 'esbuild';
// This check opens Electron and uses the clipboard. Keep it out of normal code-only lanes.
const electronTest =
process.env.SUBMINER_ELECTRON_TESTS === '1' &&
(process.platform !== 'linux' || process.env.DISPLAY)
? test
: test.skip;
electronTest(
'sidebar selection copies clean chronological text without seeking or losing context',
{
timeout: 30_000,
},
async () => {
const dir = await mkdtemp(join(tmpdir(), 'subminer-sidebar-selection-'));
await build({
entryPoints: [resolve('src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts')],
bundle: true,
platform: 'browser',
format: 'iife',
globalName: 'sidebarTest',
outfile: join(dir, 'fixture.js'),
});
const html = (await readFile('src/renderer/index.html', 'utf8'))
.replace(
'<script type="module" src="renderer.js"></script>',
'<script src="fixture.js"></script>',
)
.replace(
'href="style.css"',
`href="${new URL(`file://${resolve('src/renderer/style.css')}`).href}"`,
);
await writeFile(join(dir, 'index.html'), html);
await writeFile(
join(dir, 'clipboard.cjs'),
'const { clipboard, contextBridge } = require("electron"); contextBridge.exposeInMainWorld("copyTestSelection", text => clipboard.writeText(text));',
);
await build({
entryPoints: [resolve('src/core/services/overlay-window-input.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: join(dir, 'input.cjs'),
});
await writeFile(
join(dir, 'run.cjs'),
`
const { app, BrowserWindow, clipboard } = require('electron');
const assert = require('node:assert/strict');
const { handleOverlayWindowBeforeInputEvent } = require('./input.cjs');
app.setPath('userData', ${JSON.stringify(join(dir, 'user-data'))});
app.whenReady().then(async () => {
const window = new BrowserWindow({ width: 900, height: 700, show: false, webPreferences: { preload: ${JSON.stringify(join(dir, 'clipboard.cjs'))}, sandbox: false } });
window.webContents.on('console-message', (_event, details) => {
if (details.level === 'error') console.error(details.message);
});
let intercepted = 0;
window.webContents.on('before-input-event', (event, input) => handleOverlayWindowBeforeInputEvent({
kind: 'visible', windowVisible: true, input,
preventDefault: () => event.preventDefault(),
sendKeyboardModeToggleRequested() {}, sendLookupWindowToggleRequested() {}, forwardTabToMpv() {},
tryHandleOverlayShortcutLocalFallback() { intercepted++; return true; },
}));
await window.loadFile(${JSON.stringify(join(dir, 'index.html'))});
const run = (code) => window.webContents.executeJavaScript(code, true);
await run('sidebarTest.setup().then(checks => { window.checks = checks; })');
const expected = { text: 'の台詞\\n\\n同じ台詞\\n二行目\\n\\n同じ', cueCount: 3 };
assert.deepEqual(await run('checks.select()'), expected);
assert.deepEqual(await run('checks.select(true)'), expected);
assert.equal(await run('checks.buttonVisible()'), true);
assert.equal(await run('checks.clickCue()'), 0);
assert.equal(await run('checks.updatePlayback()'), 0);
assert.deepEqual(await run('checks.selected()'), expected);
const previousClipboard = clipboard.readText();
try {
clipboard.writeText('sentinel');
window.show(); app.focus({ steal: true }); window.focus(); window.webContents.focus();
await new Promise(resolve => setTimeout(resolve, 100));
await run('checks.clear()');
const [start, end] = await run('checks.dragPoints()');
window.webContents.sendInputEvent({ type: 'mouseDown', ...start, button: 'left', clickCount: 1 });
window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' });
window.webContents.sendInputEvent({ type: 'mouseUp', ...end, button: 'left', clickCount: 1 });
await new Promise(resolve => setTimeout(resolve, 100));
assert.deepEqual(await run('checks.selected()'), expected);
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(intercepted, 0);
assert.equal(await run('checks.fallbackCopies()'), 0);
assert.equal(clipboard.readText() === expected.text, true, 'Keyboard copies the selected excerpt');
clipboard.writeText('sentinel');
await run('checks.clickCopy()');
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(clipboard.readText() === expected.text, true, 'Button copies the selected excerpt');
} finally { clipboard.writeText(previousClipboard); }
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape", bubbles:true}))');
assert.equal(await run('checks.selected()'), null);
assert.equal(await run('checks.buttonVisible()'), false);
assert.equal(await run('checks.clickCue()'), 1);
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"c", ctrlKey:true, bubbles:true}))');
assert.equal(await run('checks.fallbackCopies()'), 1);
await run('checks.select()');
assert.equal(await run('checks.changeSource()'), null);
await run('checks.select(); checks.close()');
assert.equal(await run('checks.selected()'), null);
window.destroy();
console.log('SIDEBAR_SELECTION_OK');
app.quit();
}).catch(error => { console.error(error); app.exit(1); });
`,
);
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const { stdout } = await promisify(execFile)(
resolve('node_modules/.bin/electron'),
[join(dir, 'run.cjs')],
{ env, timeout: 25_000 },
);
assert.match(stdout, /SIDEBAR_SELECTION_OK/);
},
);
@@ -1,150 +0,0 @@
import type { RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
function isEditingText(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement)
);
}
function getSelectionRange(list: HTMLElement): Range | null {
const selection = list.ownerDocument?.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return list.contains(range.startContainer) && list.contains(range.endContainer) ? range : null;
}
export function hasSubtitleSidebarSelection(list: HTMLElement): boolean {
return getSelectionRange(list) !== null;
}
// Read only dialogue nodes, preserving partial first/last lines and DOM cue order.
export function getSubtitleSidebarSelection(list: HTMLElement): {
text: string;
cueCount: number;
} | null {
const range = getSelectionRange(list);
if (!range) return null;
const parts: string[] = [];
for (const text of list.querySelectorAll<HTMLElement>('.subtitle-sidebar-text')) {
if (!range.intersectsNode(text)) continue;
const part = list.ownerDocument.createRange();
part.selectNodeContents(text);
if (range.compareBoundaryPoints(Range.START_TO_START, part) > 0) {
part.setStart(range.startContainer, range.startOffset);
}
if (range.compareBoundaryPoints(Range.END_TO_END, part) < 0) {
part.setEnd(range.endContainer, range.endOffset);
}
const selectedText = part.toString();
if (selectedText.trim()) parts.push(selectedText);
}
return parts.length > 0 ? { text: parts.join('\n\n'), cueCount: parts.length } : null;
}
export function clearSubtitleSidebarSelection(list: HTMLElement): void {
const selection = list.ownerDocument?.defaultView?.getSelection();
if (selection?.anchorNode && list.contains(selection.anchorNode)) {
selection.removeAllRanges();
}
}
export function wireSubtitleSidebarSelection(ctx: RendererContext): () => void {
const list = ctx.dom.subtitleSidebarList;
const button = ctx.dom.subtitleSidebarCopy;
const doc = list.ownerDocument;
const abort = new AbortController();
const { signal } = abort;
const updateButton = () => {
const selected = getSubtitleSidebarSelection(list);
button.hidden = !selected;
button.textContent = selected
? `Copy ${selected.cueCount} ${selected.cueCount === 1 ? 'line' : 'lines'}`
: 'Copy';
};
const copied = () => {
ctx.dom.subtitleSidebarStatus.textContent = 'Selection copied.';
};
const copySelection = async () => {
const selected = getSubtitleSidebarSelection(list);
if (!selected) return;
try {
await window.electronAPI.copySubtitleSidebarSelection(selected.text);
copied();
} catch {
ctx.dom.subtitleSidebarStatus.textContent = 'Could not copy selection. Try again.';
}
};
doc.addEventListener('selectionchange', updateButton, { signal });
doc.addEventListener(
'copy',
(event) => {
if (!ctx.state.subtitleSidebarModalOpen || isEditingText(event.target)) return;
const selected = getSubtitleSidebarSelection(list);
if (!selected || !event.clipboardData) return;
event.preventDefault();
event.clipboardData.setData('text/plain', selected.text);
copied();
},
{ signal },
);
// Capture before modal Escape handling and the current-subtitle copy shortcut.
doc.addEventListener(
'keydown',
(event) => {
if (
!ctx.state.subtitleSidebarModalOpen ||
isEditingText(event.target) ||
!getSubtitleSidebarSelection(list)
)
return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
clearSubtitleSidebarSelection(list);
updateButton();
} else if (
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopImmediatePropagation();
void copySelection();
}
},
{ capture: true, signal },
);
button.addEventListener('mousedown', (event) => event.preventDefault(), { signal });
button.addEventListener('click', copySelection, { signal });
list.addEventListener(
'pointerdown',
(event) => {
if (event.button === 0) {
list.dataset.selecting = 'true';
list.scrollTo({ top: list.scrollTop, behavior: 'instant' });
syncOverlayMouseIgnoreState(ctx);
}
},
{ signal },
);
const stopDragging = () => {
delete list.dataset.selecting;
syncOverlayMouseIgnoreState(ctx);
};
doc.addEventListener('pointerup', stopDragging, { signal });
doc.addEventListener('pointercancel', stopDragging, { signal });
doc.defaultView?.addEventListener('blur', stopDragging, { signal });
updateButton();
return () => {
abort.abort();
stopDragging();
};
}
@@ -239,7 +239,6 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const modalNotifications: string[] = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 3.4, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
@@ -382,7 +381,6 @@ test('subtitle sidebar rows support keyboard activation', async () => {
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
@@ -489,7 +487,6 @@ test('subtitle sidebar renders hour-long cue timestamps as HH:MM:SS', async () =
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 3665, endTime: 3670, text: 'long cue' }],
currentSubtitle: {
text: 'long cue',
@@ -583,7 +580,6 @@ test('subtitle sidebar does not open when the feature is disabled', async () =>
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [],
currentSubtitle: {
text: '',
@@ -680,7 +676,6 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
const previousDocument = globals.document;
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -791,7 +786,6 @@ test('subtitle sidebar auto-open restores previously open sidebar after renderer
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -890,7 +884,6 @@ test('subtitle sidebar refresh closes and clears state when config becomes disab
const previousDocument = globals.document;
const bodyClassList = createClassList();
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1015,7 +1008,6 @@ test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timi
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 3, endTime: 4, text: 'other' },
@@ -1133,7 +1125,6 @@ test('subtitle sidebar does not regress to previous cue on text-only transition
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
@@ -1242,7 +1233,6 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
const previousDocument = globals.document;
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: Array.from({ length: 12 }, (_, index) => ({
startTime: index * 2,
endTime: index * 2 + 1.5,
@@ -1413,7 +1403,6 @@ test('subtitle sidebar polling schedules serialized timeouts instead of interval
});
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1526,7 +1515,6 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1645,7 +1633,6 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1765,7 +1752,6 @@ test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains op
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1895,7 +1881,6 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin',
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2055,7 +2040,6 @@ test('subtitle sidebar embedded layout measures reserved width after embedded cl
const contentClassList = createClassList();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2177,7 +2161,6 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2307,7 +2290,6 @@ test('subtitle sidebar overlay layout restores macOS and Windows passthrough out
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2435,7 +2417,6 @@ test('subtitle sidebar overlay layout only stays interactive while focus remains
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2551,7 +2532,6 @@ test('closing embedded subtitle sidebar recomputes passthrough from remaining su
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2660,7 +2640,6 @@ test('subtitle sidebar resets embedded mpv margin on startup while closed', asyn
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
-16
View File
@@ -7,10 +7,6 @@ import type {
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
clearSubtitleSidebarSelection,
hasSubtitleSidebarSelection,
} from './subtitle-sidebar-selection.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
@@ -213,7 +209,6 @@ export function createSubtitleSidebarModal(
let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -474,8 +469,6 @@ export function createSubtitleSidebarModal(
): void {
if (
!ctx.state.subtitleSidebarAutoScroll ||
ctx.dom.subtitleSidebarList.dataset?.selecting === 'true' ||
hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList) ||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
@@ -572,14 +565,8 @@ export function createSubtitleSidebarModal(
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1;
subtitleSourceKey = snapshot.sourceKey;
}
applyConfig(snapshot);
if (!snapshot.config.enabled) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.subtitleSidebarCues = [];
@@ -599,7 +586,6 @@ export function createSubtitleSidebarModal(
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
if (cuesChanged) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
ctx.state.subtitleSidebarCues = snapshot.cues;
if (ctx.state.subtitleSidebarModalOpen) {
renderCueList();
@@ -684,7 +670,6 @@ export function createSubtitleSidebarModal(
if (!ctx.state.subtitleSidebarModalOpen) {
return;
}
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.subtitleSidebarModalOpen = false;
@@ -725,7 +710,6 @@ export function createSubtitleSidebarModal(
closeSubtitleSidebarModal();
});
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
if (hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList)) return;
const target = event.target;
if (!(target instanceof Element)) {
return;
+4 -213
View File
@@ -40,19 +40,13 @@ function createElementStub() {
}
function createListStub() {
const list = {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
list.children.push(child);
this.children.push(child);
},
};
// The modal clears lists through innerHTML before re-rendering.
return Object.defineProperty(list, 'innerHTML', {
get: () => '',
set: () => {
list.children.length = 0;
},
}) as typeof list & { innerHTML: string };
}
function createTabStub(active: boolean) {
@@ -124,7 +118,6 @@ function createModalHarness(
files: TsukihimeSubtitleFile[],
options: {
secondaryLanguages?: string[];
secondaryLanguagesGate?: Promise<void>;
downloadFile?: (query: unknown) => Promise<unknown>;
listFiles?: (entryId: number) => Promise<unknown>;
searchEntries?: (query: unknown) => Promise<unknown>;
@@ -145,10 +138,7 @@ function createModalHarness(
if (options.downloadFile) return options.downloadFile(query);
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
},
tsukihimeGetSecondaryLanguages: async () => {
await options.secondaryLanguagesGate;
return options.secondaryLanguages ?? ['en', 'eng'];
},
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
tsukihimeSearchEntries: async (query: unknown) =>
@@ -623,202 +613,3 @@ test('renderFiles omits the size detail when the API does not report one', () =>
harness.restoreGlobals();
}
});
const ENGLISH_ONLY_ENTRY = {
id: 606713,
title: 'english only release',
timestamp: null,
totalSize: null,
numFiles: 1,
sublangs: ['en'],
};
const MULTI_SUB_ENTRY = {
id: 12255,
title: 'multi-sub release',
timestamp: null,
totalSize: null,
numFiles: 1,
sublangs: ['en-US', 'ja'],
};
const UNLABELED_ENTRY = {
id: 12256,
title: 'release without langs',
timestamp: null,
totalSize: null,
numFiles: 1,
sublangs: [],
};
function visibleEntryTitles(harness: ModalHarness): string[] {
return (harness.entriesList.children as Array<{ textContent: string }>).map(
(li) => li.textContent,
);
}
test('Japanese tab lists only releases that carry Japanese subtitles', async () => {
const SECOND_JAPANESE_TRACK: TsukihimeSubtitleFile = {
...JAPANESE_TRACK,
attachmentId: 1955401,
filename: 'episode01.jpn.sdh.ass',
};
const harness = createModalHarness([], {
// Two tracks so the modal does not auto-download a lone match.
listFiles: async () => ({ ok: true, data: [JAPANESE_TRACK, SECOND_JAPANESE_TRACK] }),
});
try {
harness.state.currentTsukihimeEntryId = null;
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, MULTI_SUB_ENTRY, UNLABELED_ENTRY];
pressKey(harness, 'ArrowRight');
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
// Enter addresses the visible list, so it must pick the multi-sub release
// rather than the hidden first search result.
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.equal(harness.state.currentTsukihimeEntryId, MULTI_SUB_ENTRY.id);
assert.equal(harness.status.textContent, 'Select a subtitle track.');
pressKey(harness, 'ArrowLeft');
assert.deepEqual(visibleEntryTitles(harness), [
'english only release',
'multi-sub release',
'release without langs',
]);
assert.equal(harness.state.currentTsukihimeEntryId, MULTI_SUB_ENTRY.id);
assert.equal(harness.state.selectedTsukihimeEntryIndex, 1);
} finally {
harness.restoreGlobals();
}
});
test('Japanese tab reports when no release carries Japanese subtitles', () => {
const harness = createModalHarness([]);
try {
harness.state.currentTsukihimeEntryId = null;
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, UNLABELED_ENTRY];
pressKey(harness, 'ArrowRight');
assert.deepEqual(visibleEntryTitles(harness), []);
assert.equal(
harness.status.textContent,
'No releases with Japanese subtitles. Switch to the English tab.',
);
pressKey(harness, 'ArrowLeft');
assert.deepEqual(visibleEntryTitles(harness), [
'english only release',
'release without langs',
]);
assert.equal(harness.status.textContent, 'Select a release.');
} finally {
harness.restoreGlobals();
}
});
test('search reports when no release carries the secondary language', async () => {
const harness = createModalHarness([], {
searchEntries: async () => ({
ok: true,
data: [{ ...MULTI_SUB_ENTRY, sublangs: ['ja'] }],
}),
});
try {
harness.state.currentTsukihimeEntryId = null;
harness.titleInput.value = 'Futsutsuka na Akujo';
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(visibleEntryTitles(harness), []);
assert.equal(
harness.status.textContent,
'No releases with English subtitles. Switch to the Japanese tab.',
);
pressKey(harness, 'ArrowRight');
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
} finally {
harness.restoreGlobals();
}
});
test('switching to a tab that hides the selected release clears its tracks', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, MULTI_SUB_ENTRY];
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.currentTsukihimeEntryId, null);
assert.deepEqual(harness.state.tsukihimeFiles, []);
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
assert.equal(harness.status.textContent, 'Select a release.');
} finally {
harness.restoreGlobals();
}
});
test('a search waits for the configured secondary languages before filtering', async () => {
let openGate!: () => void;
const harness = createModalHarness([], {
secondaryLanguages: ['de'],
secondaryLanguagesGate: new Promise<void>((resolve) => {
openGate = resolve;
}),
searchEntries: async () => ({
ok: true,
data: [{ ...MULTI_SUB_ENTRY, title: 'german release', sublangs: ['de'] }],
}),
});
try {
harness.state.tsukihimeModalOpen = false;
harness.modal.openTsukihimeModal();
harness.titleInput.value = 'Futsutsuka na Akujo';
// Searching before the config arrives must not filter against the English
// fallback, which would hide this German-only release.
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(visibleEntryTitles(harness), []);
openGate();
await flushAsyncWork();
assert.deepEqual(visibleEntryTitles(harness), ['german release']);
} finally {
harness.restoreGlobals();
}
});
test('a search from a prior modal session cannot repopulate a reopened modal', async () => {
let openGate!: () => void;
const harness = createModalHarness([], {
secondaryLanguagesGate: new Promise<void>((resolve) => {
openGate = resolve;
}),
searchEntries: async () => ({ ok: true, data: [MULTI_SUB_ENTRY] }),
});
try {
harness.state.tsukihimeModalOpen = false;
harness.modal.openTsukihimeModal();
harness.titleInput.value = 'Futsutsuka na Akujo';
// The search parks on the language config, then the user closes and
// reopens the modal before it resolves.
pressKey(harness, 'Enter');
harness.modal.closeTsukihimeModal();
harness.modal.openTsukihimeModal();
await flushAsyncWork();
harness.status.textContent = 'Fresh modal session';
openGate();
await flushAsyncWork();
assert.deepEqual(harness.state.tsukihimeEntries, []);
assert.deepEqual(visibleEntryTitles(harness), []);
assert.equal(harness.status.textContent, 'Fresh modal session');
} finally {
harness.restoreGlobals();
}
});
+23 -105
View File
@@ -41,13 +41,7 @@ export function createTsukihimeModal(
// Defaults to English until the configured secondary languages arrive.
let secondaryLanguages: string[] = ['en'];
// Both tab filters read the configured languages, so a search must wait for
// them rather than filtering against the English fallback.
let secondaryLanguagesReady: Promise<void> = Promise.resolve();
let activeDownloadToken = 0;
// Bumped by every new search and by closing the modal, so results that
// arrive late cannot repopulate a reopened modal or a newer search.
let activeSearchToken = 0;
function secondaryTabLabel(): string {
return describeTsukihimeTabLanguages(secondaryLanguages);
@@ -67,47 +61,6 @@ export function createTsukihimeModal(
);
}
// Releases are filtered by the languages the search index reports for
// them. Most releases carry no Japanese track, so the primary tab hides
// them outright. A release with no language data cannot be classified and
// stays on the secondary tab, mirroring how unlabeled tracks are handled.
function entryMatchesTab(entry: TsukihimeEntry, tab: 'secondary' | 'primary'): boolean {
if (tab === 'primary') {
return entry.sublangs.some((lang) => normalizeTsukihimeLangCode(lang) === 'ja');
}
if (entry.sublangs.length === 0) return true;
return entry.sublangs.some(
(lang) =>
normalizeTsukihimeLangCode(lang) !== 'ja' &&
tsukihimeTrackMatchesLanguages(lang, secondaryLanguages),
);
}
function getVisibleEntries(): TsukihimeEntry[] {
return ctx.state.tsukihimeEntries.filter((entry) =>
entryMatchesTab(entry, ctx.state.tsukihimeActiveTab),
);
}
function describeEmptyReleases(): string {
const otherTab = ctx.state.tsukihimeActiveTab === 'primary' ? 'secondary' : 'primary';
const otherTabHasReleases = ctx.state.tsukihimeEntries.some((entry) =>
entryMatchesTab(entry, otherTab),
);
const language = ctx.state.tsukihimeActiveTab === 'primary' ? 'Japanese' : secondaryTabLabel();
const otherLabel = otherTab === 'primary' ? 'Japanese' : secondaryTabLabel();
return otherTabHasReleases
? `No releases with ${language} subtitles. Switch to the ${otherLabel} tab.`
: `No releases with ${language} subtitles.`;
}
function clearFiles(): void {
ctx.state.tsukihimeFiles = [];
ctx.state.selectedTsukihimeFileIndex = 0;
ctx.dom.tsukihimeFilesList.innerHTML = '';
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
}
function renderTabs(): void {
const primaryActive = ctx.state.tsukihimeActiveTab === 'primary';
ctx.dom.tsukihimeTabSecondaryButton.setAttribute(
@@ -145,37 +98,12 @@ export function createTsukihimeModal(
ctx.state.selectedTsukihimeFileIndex = 0;
renderTabs();
const currentEntry = ctx.state.tsukihimeEntries.find(
(entry) => entry.id === ctx.state.currentTsukihimeEntryId,
);
if (currentEntry && !entryMatchesTab(currentEntry, tab)) {
// The selected release is hidden on this tab; drop its tracks so the
// list matches what the tab claims to show.
ctx.state.currentTsukihimeEntryId = null;
ctx.state.selectedTsukihimeEntryIndex = 0;
clearFiles();
renderEntries();
setTsukihimeStatus(
getVisibleEntries().length === 0 ? describeEmptyReleases() : 'Select a release.',
);
return;
}
const visibleEntries = getVisibleEntries();
ctx.state.selectedTsukihimeEntryIndex = currentEntry ? visibleEntries.indexOf(currentEntry) : 0;
renderEntries();
if (ctx.state.tsukihimeFiles.length > 0) {
renderFiles();
setTsukihimeStatus(
getVisibleFiles().length === 0 ? describeEmptyTab() : 'Select a subtitle track.',
);
return;
}
if (!currentEntry && ctx.state.tsukihimeEntries.length > 0) {
setTsukihimeStatus(
visibleEntries.length === 0 ? describeEmptyReleases() : 'Select a release.',
);
if (ctx.state.tsukihimeFiles.length === 0) return;
renderFiles();
if (getVisibleFiles().length === 0) {
setTsukihimeStatus(describeEmptyTab());
} else {
setTsukihimeStatus('Select a subtitle track.');
}
}
@@ -193,14 +121,13 @@ export function createTsukihimeModal(
function renderEntries(): void {
ctx.dom.tsukihimeEntriesList.innerHTML = '';
const visibleEntries = getVisibleEntries();
if (visibleEntries.length === 0) {
if (ctx.state.tsukihimeEntries.length === 0) {
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
return;
}
ctx.dom.tsukihimeEntriesSection.classList.remove('hidden');
visibleEntries.forEach((entry, index) => {
ctx.state.tsukihimeEntries.forEach((entry, index) => {
const li = document.createElement('li');
li.textContent = entry.title;
@@ -283,15 +210,11 @@ export function createTsukihimeModal(
return;
}
const searchToken = ++activeSearchToken;
resetTsukihimeLists();
setTsukihimeStatus('Searching TsukiHime...');
await secondaryLanguagesReady;
if (searchToken !== activeSearchToken) return;
const response: TsukihimeApiResponse<TsukihimeEntry[]> =
await window.electronAPI.tsukihimeSearchEntries({ query });
if (searchToken !== activeSearchToken) return;
if (!response.ok) {
setTsukihimeStatus(response.error.error, true);
return;
@@ -305,22 +228,20 @@ export function createTsukihimeModal(
return;
}
const visibleEntries = getVisibleEntries();
if (visibleEntries.length === 0) {
setTsukihimeStatus(describeEmptyReleases());
return;
}
setTsukihimeStatus('Select a release.');
renderEntries();
if (visibleEntries.length === 1) {
if (ctx.state.tsukihimeEntries.length === 1) {
selectEntry(0);
}
}
async function loadFiles(entryId: number): Promise<void> {
setTsukihimeStatus('Loading subtitle tracks...');
clearFiles();
ctx.state.tsukihimeFiles = [];
ctx.state.selectedTsukihimeFileIndex = 0;
ctx.dom.tsukihimeFilesList.innerHTML = '';
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
const response: TsukihimeApiResponse<TsukihimeSubtitleFile[]> =
await window.electronAPI.tsukihimeListFiles({ entryId });
@@ -358,14 +279,11 @@ export function createTsukihimeModal(
}
}
// `index` addresses the entries visible on the active tab, not the full
// search result list.
function selectEntry(index: number): void {
const visibleEntries = getVisibleEntries();
if (index < 0 || index >= visibleEntries.length) return;
if (index < 0 || index >= ctx.state.tsukihimeEntries.length) return;
ctx.state.selectedTsukihimeEntryIndex = index;
ctx.state.currentTsukihimeEntryId = visibleEntries[index]!.id;
ctx.state.currentTsukihimeEntryId = ctx.state.tsukihimeEntries[index]!.id;
renderEntries();
if (ctx.state.currentTsukihimeEntryId !== null) {
@@ -445,15 +363,16 @@ export function createTsukihimeModal(
resetTsukihimeLists();
renderTabs();
secondaryLanguagesReady = loadSecondaryLanguages();
const secondaryLanguagesReady = loadSecondaryLanguages();
window.electronAPI
.getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => {
.then(async (info: JimakuMediaInfo) => {
ctx.dom.tsukihimeTitleInput.value = info.title || '';
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
await secondaryLanguagesReady;
void performTsukihimeSearch();
} else if (info.title) {
setTsukihimeStatus('Check title/episode and press Search.');
@@ -470,7 +389,6 @@ export function createTsukihimeModal(
if (!ctx.state.tsukihimeModalOpen) return;
activeDownloadToken += 1;
activeSearchToken += 1;
ctx.state.tsukihimeModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.tsukihimeModal.classList.add('hidden');
@@ -520,9 +438,9 @@ export function createTsukihimeModal(
ctx.state.selectedTsukihimeFileIndex + 1,
);
renderFiles();
} else if (getVisibleEntries().length > 0) {
} else if (ctx.state.tsukihimeEntries.length > 0) {
ctx.state.selectedTsukihimeEntryIndex = Math.min(
getVisibleEntries().length - 1,
ctx.state.tsukihimeEntries.length - 1,
ctx.state.selectedTsukihimeEntryIndex + 1,
);
renderEntries();
@@ -538,7 +456,7 @@ export function createTsukihimeModal(
ctx.state.selectedTsukihimeFileIndex - 1,
);
renderFiles();
} else if (getVisibleEntries().length > 0) {
} else if (ctx.state.tsukihimeEntries.length > 0) {
ctx.state.selectedTsukihimeEntryIndex = Math.max(
0,
ctx.state.selectedTsukihimeEntryIndex - 1,
@@ -552,7 +470,7 @@ export function createTsukihimeModal(
e.preventDefault();
if (getVisibleFiles().length > 0) {
void selectFile(ctx.state.selectedTsukihimeFileIndex);
} else if (getVisibleEntries().length > 0) {
} else if (ctx.state.tsukihimeEntries.length > 0) {
selectEntry(ctx.state.selectedTsukihimeEntryIndex);
} else {
void performTsukihimeSearch();
+1 -3
View File
@@ -27,9 +27,7 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldKeepWindowInteractive =
ctx.dom.subtitleSidebarList?.dataset?.selecting === 'true' ||
isYomitanPopupInteractionActive(ctx.state) ||
isBlockingOverlayModalOpen(ctx.state);
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
-8
View File
@@ -40,7 +40,6 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createChangelogModal } from './modals/changelog.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
@@ -240,8 +239,6 @@ const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
measurementReporter.emitNow();
},
});
const disposeSubtitleSidebarSelection = wireSubtitleSidebarSelection(ctx);
window.addEventListener('beforeunload', disposeSubtitleSidebarSelection, { once: true });
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -594,11 +591,6 @@ function registerModalOpenHandlers(): void {
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
});
});
window.electronAPI.onMediaTimingReviewPreviewEnded((reviewId) => {
runGuarded('media-timing-review:preview-ended', () => {
mediaTimingReviewModal.handlePreviewEnded(reviewId);
});
});
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
-3
View File
@@ -3690,7 +3690,6 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
}
.subtitle-sidebar-timestamp {
user-select: none;
font-size: 0.72em;
font-weight: 600;
font-variant-numeric: tabular-nums;
@@ -3713,8 +3712,6 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
}
.subtitle-sidebar-text {
user-select: text;
cursor: text;
white-space: pre-wrap;
line-height: 1.5;
font-size: 1em;
-2
View File
@@ -157,7 +157,6 @@ export type RendererDom = {
subtitleSidebarModal: HTMLDivElement;
subtitleSidebarContent: HTMLDivElement;
subtitleSidebarClose: HTMLButtonElement;
subtitleSidebarCopy: HTMLButtonElement;
subtitleSidebarStatus: HTMLDivElement;
subtitleSidebarList: HTMLUListElement;
@@ -406,7 +405,6 @@ export function resolveRendererDom(): RendererDom {
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
subtitleSidebarCopy: getRequiredElement<HTMLButtonElement>('subtitleSidebarCopy'),
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
-1
View File
@@ -148,7 +148,6 @@ export const IPC_CHANNELS = {
tsukihimeOpen: 'tsukihime:open',
youtubePickerOpen: 'youtube:picker-open',
mediaTimingReviewOpen: 'media-timing-review:open',
mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended',
youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
-2
View File
@@ -79,8 +79,6 @@ export interface MediaTimingReviewResolveRequest {
export interface MediaTimingReviewActionResult {
ok: boolean;
message?: string;
/** The review this request targeted has already ended; the renderer should close. */
stale?: boolean;
}
export interface NotificationOptions {
-2
View File
@@ -448,7 +448,6 @@ export interface ElectronAPI {
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
copySubtitleSidebarSelection: (text: string) => Promise<void>;
getSubtitleSidebarOpen: () => Promise<boolean>;
getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void;
@@ -524,7 +523,6 @@ export interface ElectronAPI {
onOpenTsukihime: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void;
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
-1
View File
@@ -227,7 +227,6 @@ export interface SubtitleData {
}
export interface SubtitleSidebarSnapshot {
sourceKey: string | null;
cues: SubtitleCue[];
currentTimeSec?: number | null;
currentSubtitle: {