mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
3 Commits
7e86c4ea3d
...
da3c971ee6
| Author | SHA1 | Date | |
|---|---|---|---|
| da3c971ee6 | |||
|
c02edc90cc
|
|||
|
4d1a20d69b
|
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Made sentence-audio padding opt-in by default, and kept animated AVIF motion aligned when padding is configured by freezing the first frame during leading audio padding.
|
||||
- Kept multi-line sentence mining aligned when repeated subtitle text appears in the selected history range.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed animated AVIF word-audio sync so the frozen lead-in matches the word audio duration without adding sentence audio padding a second time.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: shortcuts
|
||||
|
||||
- Focus the visible overlay when entering multi-line copy/mine selection so number keys choose the line count on macOS and Windows.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Downloaded selected YouTube primary subtitles to temporary local files so the primary bar and sidebar read the same subtitle source, with temp-file cleanup on reload and quit. Suppressed stale failure notifications by re-checking live mpv subtitle state before reporting primary subtitle load failures.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Suppressed false YouTube primary subtitle failure notifications after SubMiner confirms the selected primary track loaded successfully.
|
||||
- Ensured launcher-managed playback commands create the tray icon even when they attach to an already-running SubMiner process.
|
||||
- Prevented app-owned YouTube playback from letting the mpv plugin start a second SubMiner process after the launcher already started one.
|
||||
- Logged Linux tray registration failures with a StatusNotifier/AppIndicator hint and documented the Hyprland tray-host requirement.
|
||||
@@ -488,6 +488,7 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -507,11 +508,14 @@
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"imageMaxWidth": 0, // Maximum width for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"imageMaxHeight": 0, // Maximum height for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
@@ -555,6 +559,8 @@
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
@@ -4,6 +4,8 @@ SubMiner can sync your watch progress to [AniList](https://anilist.co) automatic
|
||||
|
||||
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 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
|
||||
|
||||
AniList integration is opt-in. To enable it:
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
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.
|
||||
|
||||
::: tip New to these terms?
|
||||
- **Anki** is the flashcard app where your study cards live.
|
||||
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
||||
- A **note type** (also called a "model") is the template that defines what a card looks like — for example the Kiku or Lapis templates many Japanese learners use.
|
||||
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install [Anki](https://apps.ankiweb.net/).
|
||||
@@ -15,9 +22,9 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
||||
|
||||
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** — SubMiner runs a local AnkiConnect-compatible proxy and intercepts card creation instantly. Recommended when possible.
|
||||
**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** (default) — SubMiner polls AnkiConnect every few seconds for newly added cards. Simpler setup, but with a short delay (~3 seconds).
|
||||
**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 if you want immediate enrichment. Use polling mode if your Yomitan instance is external (browser-based) or you prefer minimal configuration.
|
||||
|
||||
@@ -147,13 +154,13 @@ SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg mu
|
||||
|
||||
### Audio
|
||||
|
||||
Audio is extracted from the video file using the subtitle's start and end timestamps, with configurable padding added before and after.
|
||||
Audio is extracted from the video file using the subtitle's start and end timestamps. Padding is opt-in; keep it at `0` when you want sentence audio to start exactly at the mined sentence.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"audioPadding": 0.5, // seconds before and after subtitle timing
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
@@ -322,6 +329,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"upstreamUrl": "http://127.0.0.1:8765",
|
||||
},
|
||||
"fields": {
|
||||
"word": "Expression",
|
||||
"audio": "ExpressionAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
@@ -334,7 +342,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"audioPadding": 0.5,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
"behavior": {
|
||||
|
||||
@@ -273,7 +273,7 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
||||
|
||||
- **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.
|
||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to mpv subtitle/playback properties via `observe_property`), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
|
||||
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
|
||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Character Dictionary
|
||||
|
||||
SubMiner can build a Yomitan-compatible character dictionary from AniList 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.
|
||||
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.)
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+25
-30
@@ -8,6 +8,8 @@ outline: [2, 3]
|
||||
import { withBase } from 'vitepress';
|
||||
</script>
|
||||
|
||||
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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
For most users, start with this minimal configuration:
|
||||
@@ -228,22 +230,15 @@ Control whether the overlay automatically becomes visible when it connects to mp
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_start_overlay": false
|
||||
"auto_start_overlay": true
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | --------------- | ------------------------------------------------------ |
|
||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
||||
| Option | Values | Description |
|
||||
| -------------------- | --------------- | ----------------------------------------------------- |
|
||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
|
||||
|
||||
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
|
||||
For wrapper-driven playback, `subminer.conf` can also enable startup pause gating with
|
||||
`auto_start_pause_until_ready` (requires `auto_start=yes` + `auto_start_visible_overlay=yes`).
|
||||
Current plugin defaults in `subminer.conf` are:
|
||||
|
||||
- `auto_start=yes`
|
||||
- `auto_start_visible_overlay=yes`
|
||||
- `auto_start_pause_until_ready=yes`
|
||||
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime — there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
|
||||
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
|
||||
|
||||
@@ -367,7 +362,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"fontColor": "#cad3f5",
|
||||
"backgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"font-size": "24px",
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||
}
|
||||
@@ -428,7 +423,7 @@ Character-name highlighting is separate from N+1 and frequency highlighting:
|
||||
- `nameMatchColor` sets the highlight color for those matched character names.
|
||||
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled.
|
||||
|
||||
Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, sans-serif"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `textShadow: "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"`, `backgroundColor: "transparent"`, `fontWeight: "600"`. Any property not set in `secondary` falls back to the CSS defaults.
|
||||
Secondary subtitle defaults: `fontFamily: "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `textShadow: "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"`, `backgroundColor: "transparent"`, `fontWeight: "600"`. Any property not set in `secondary` falls back to the CSS defaults.
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||
|
||||
@@ -445,7 +440,7 @@ Configure the parsed-subtitle sidebar modal.
|
||||
"toggleKey": "Backslash",
|
||||
"pauseVideoOnHover": true,
|
||||
"autoScroll": true,
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"fontSize": 16
|
||||
}
|
||||
}
|
||||
@@ -483,7 +478,7 @@ For full details on layout modes, behavior, and the keyboard shortcut, see the [
|
||||
| `N1` | `#ed8796` | JLPT N1 underline color |
|
||||
| `N2` | `#f5a97f` | JLPT N2 underline color |
|
||||
| `N3` | `#f9e2af` | JLPT N3 underline color |
|
||||
| `N4` | `#a6e3a1` | JLPT N4 underline color |
|
||||
| `N4` | `#8bd5ca` | JLPT N4 underline color |
|
||||
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
||||
|
||||
**Image Quality Notes:**
|
||||
@@ -855,8 +850,7 @@ Palette controls:
|
||||
|
||||
### Shared AI Provider
|
||||
|
||||
Shared OpenAI-compatible transport settings live at the top level under `ai`.
|
||||
Anki reads this provider directly. Legacy subtitle fallback keeps the same provider shape for compatibility, then applies feature-local overrides where supported.
|
||||
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 (today: Anki translation/enrichment). Per-feature toggles and prompt/model tweaks live in their own sections (for example `ankiConnect.ai`) and inherit this transport.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -864,21 +858,22 @@ Anki reads this provider directly. Legacy subtitle fallback keeps the same provi
|
||||
"enabled": false,
|
||||
"apiKey": "",
|
||||
"apiKeyCommand": "",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"requestTimeoutMs": 15000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable shared AI provider features |
|
||||
| `apiKey` | string | Static API key for the shared provider |
|
||||
| `apiKeyCommand` | string | Shell command used to resolve the API key |
|
||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL |
|
||||
| `model` | string | Optional model override for shared provider workflows |
|
||||
| `systemPrompt` | string | Optional system prompt override for shared provider workflows |
|
||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `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:
|
||||
|
||||
@@ -895,7 +890,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"pollingRate": 3000,
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8766,
|
||||
"upstreamUrl": "http://127.0.0.1:8765"
|
||||
@@ -927,7 +922,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 360,
|
||||
"animatedCrf": 35,
|
||||
"audioPadding": 0.5,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
},
|
||||
@@ -989,7 +984,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Feature Demos
|
||||
|
||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
||||
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';
|
||||
|
||||
@@ -68,10 +68,15 @@ make dev-watch-macos # same as dev-watch, forcing --bac
|
||||
```
|
||||
|
||||
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
||||
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
|
||||
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
|
||||
the mpv plugin at runtime:
|
||||
|
||||
```ini
|
||||
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
|
||||
```json
|
||||
{
|
||||
"mpv": {
|
||||
"subminerBinaryPath": "/absolute/path/to/SubMiner/scripts/subminer-dev.sh"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
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 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.)
|
||||
|
||||
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
|
||||
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`.
|
||||
|
||||
## Enabling
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Installation
|
||||
|
||||
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:
|
||||
|
||||
1. **Install requirements** — mpv and a few optional extras
|
||||
@@ -92,7 +94,7 @@ pip install ffsubsync
|
||||
|
||||
### macOS
|
||||
|
||||
macOS 10.13 or later. Accessibility permission is required for window tracking (see [step 2](#macos-dmg)).
|
||||
macOS 11 (Big Sur) or later. Accessibility permission — the macOS setting that lets one app observe and position another app's windows — is required so the overlay can follow the mpv window (see [step 2](#macos-dmg)).
|
||||
|
||||
```bash
|
||||
brew install mpv ffmpeg
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Jellyfin Integration
|
||||
|
||||
[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 log in, browse it, and play episodes through mpv with the full mining overlay.
|
||||
|
||||
::: tip Who needs this?
|
||||
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. Most of this integration is driven from the command line, so it is aimed at slightly more advanced users; the in-app setup window (`subminer jellyfin`) is the easiest starting point.
|
||||
:::
|
||||
|
||||
SubMiner includes an optional Jellyfin CLI integration for:
|
||||
|
||||
- authenticating against a server
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Jimaku Integration
|
||||
|
||||
[Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime. 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.
|
||||
[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
|
||||
|
||||
|
||||
+83
-116
@@ -4,74 +4,15 @@ This guide walks through the sentence mining loop — from watching a video to c
|
||||
|
||||
## Overview
|
||||
|
||||
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You hover a word, trigger Yomitan lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
||||
*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.
|
||||
|
||||
## Subtitle Delivery Path (Startup + Runtime)
|
||||
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.
|
||||
|
||||
SubMiner prioritizes subtitle responsiveness over heavy initialization:
|
||||
|
||||
1. The first subtitle render is **plain text first** (no tokenization wait).
|
||||
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
||||
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
||||
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization (configurable via `startupWarmups`, including low-power mode).
|
||||
|
||||
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
||||
|
||||
## Overlay Model
|
||||
|
||||
SubMiner uses one overlay window with modal surfaces.
|
||||
|
||||
### Primary Subtitle Layer
|
||||
|
||||
The visible overlay 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`)
|
||||
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||
- Right-click to pause/resume
|
||||
- Right-click + drag to reposition subtitles
|
||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||
- **Reading annotations** — known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted
|
||||
|
||||
Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
|
||||
### Secondary Subtitle Bar
|
||||
|
||||
The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
||||
|
||||
It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up).
|
||||
4. Yomitan opens its lookup popup for the hovered token.
|
||||
5. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
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.
|
||||
|
||||
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.
|
||||
> **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
|
||||
|
||||
There are three ways to create cards, depending on your workflow.
|
||||
There are four ways to create or enrich cards, depending on your workflow.
|
||||
|
||||
### 1. Auto-Update from Yomitan
|
||||
|
||||
@@ -80,11 +21,11 @@ This is the most common flow. Yomitan creates a card in Anki, and SubMiner enric
|
||||
1. Hover a word, then trigger Yomitan lookup → Yomitan popup appears.
|
||||
2. Click the Anki icon in Yomitan to add the word.
|
||||
3. SubMiner receives or detects the new card:
|
||||
- **Proxy mode** (`ankiConnect.proxy.enabled: true`): immediate enrich after successful `addNote` / `addNotes`.
|
||||
- **Polling mode** (default): detects via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
||||
- **Proxy mode** (default, `ankiConnect.proxy.enabled: true`): immediate enrich after a successful `addNote` / `addNotes` is pushed through the local proxy.
|
||||
- **Polling mode** (fallback, when the proxy is disabled): detects new cards via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
||||
4. SubMiner updates the card with:
|
||||
- **Sentence**: The current subtitle line.
|
||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
||||
- **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.
|
||||
@@ -131,55 +72,88 @@ After adding a word via Yomitan, press the audio card shortcut to overwrite the
|
||||
Audio card marking requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration — Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
|
||||
:::
|
||||
|
||||
## Secondary Subtitles
|
||||
|
||||
SubMiner can display a secondary subtitle track (typically English) alongside the primary Japanese subtitles. This is useful for:
|
||||
|
||||
- Quick comprehension checks without leaving the mining flow.
|
||||
- Auto-populating the translation field on mined cards.
|
||||
|
||||
### Display Modes
|
||||
|
||||
Cycle through modes with the configured shortcut:
|
||||
|
||||
- **Hidden**: Secondary subtitle not shown.
|
||||
- **Visible**: Always displayed below the primary subtitle.
|
||||
- **Hover**: Only shown when you hover over the primary subtitle.
|
||||
|
||||
When a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
||||
|
||||
## Field Grouping (Kiku)
|
||||
### Field Grouping (Kiku)
|
||||
|
||||
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 similar note types that support grouped fields.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. You add a word via Yomitan.
|
||||
2. SubMiner detects the new card and checks if a card with the same expression already exists.
|
||||
3. If a duplicate is found:
|
||||
- **Auto mode** (`fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
|
||||
- **Manual mode** (`fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
|
||||
3. If a duplicate is found (this requires `ankiConnect.isKiku.fieldGrouping` to be set to `"auto"` or `"manual"`; it defaults to `"disabled"`):
|
||||
- **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
|
||||
- **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
|
||||
|
||||
See [Anki Integration — Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts.
|
||||
|
||||
## Jimaku Subtitle Search
|
||||
## Overlay Model
|
||||
|
||||
SubMiner integrates with [Jimaku](https://jimaku.cc) to search and download subtitle files for anime directly from the overlay.
|
||||
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.
|
||||
|
||||
1. Open the Jimaku modal via the configured shortcut (`Ctrl+Shift+J` by default).
|
||||
2. SubMiner auto-fills the search from the current video filename (title, season, episode).
|
||||
3. Browse matching entries and select a subtitle file to download.
|
||||
4. The subtitle is loaded into mpv as a new track.
|
||||
Toggle the entire overlay window with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
|
||||
Requires an internet connection. An API key (`jimaku.apiKey`) is optional but recommended for higher rate limits.
|
||||
### Primary Subtitle Layer
|
||||
|
||||
## Texthooker
|
||||
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:
|
||||
|
||||
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time.
|
||||
- Word-level hover targets for Yomitan lookup
|
||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||
- Right-click to pause/resume
|
||||
- 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
|
||||
|
||||
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.
|
||||
### Secondary Subtitle Bar
|
||||
|
||||
If you want to build your own browser client, websocket consumer, or automation relay, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||
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).
|
||||
|
||||
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
|
||||
|
||||
### Display Modes
|
||||
|
||||
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
||||
|
||||
- **Hidden** — the bar is not shown.
|
||||
- **Visible** — the bar is always shown.
|
||||
- **Hover** — the bar is revealed only while you hover over the overlay.
|
||||
|
||||
By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`) and the **secondary** bar is `hover` (`secondarySub.defaultMode`).
|
||||
|
||||
Cycle each bar's mode at runtime with its own shortcut:
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| -------------------- | -------------------------------------------------------- | ------------------------------ |
|
||||
| `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
|
||||
|
||||
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||
|
||||
## 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.
|
||||
3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up).
|
||||
4. Yomitan opens its lookup popup for the hovered token.
|
||||
5. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
@@ -192,27 +166,20 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
||||
|
||||
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||
|
||||
## N+1 Word Highlighting
|
||||
## Texthooker
|
||||
|
||||
When enabled, SubMiner cross-references your Anki decks to highlight known words in the overlay, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time.
|
||||
|
||||
See [Subtitle Annotations — N+1](/subtitle-annotations#n1-word-highlighting) for configuration options and color settings.
|
||||
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.
|
||||
|
||||
## Immersion Tracking
|
||||
If you want to build your own browser client, websocket consumer, or automation relay, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||
|
||||
SubMiner can log your watching and mining activity to a local SQLite database and expose it in the built-in stats dashboard — session times, words seen, cards mined, and daily/monthly rollups.
|
||||
## Related Features
|
||||
|
||||
Enable it in your config:
|
||||
These features support the mining loop but have their own dedicated pages:
|
||||
|
||||
```jsonc
|
||||
"immersionTracking": {
|
||||
"enabled": true,
|
||||
"dbPath": "" // leave empty to use the default location
|
||||
}
|
||||
```
|
||||
|
||||
Open the dashboard in the overlay with `stats.toggleKey` (default: `` ` ``), launch it in a browser with `subminer stats`, keep a dedicated background server alive with `subminer stats -b`, stop that background server with `subminer stats -s`, or visit `http://127.0.0.1:6969` directly if the local stats server is already running. The dashboard covers overview totals, anime progress, session detail, and vocabulary drill-down from the same local immersion database.
|
||||
|
||||
See [Immersion Tracking](/immersion-tracking) for dashboard details, schema, and retention settings.
|
||||
- **[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#n1-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.
|
||||
|
||||
+41
-101
@@ -1,6 +1,10 @@
|
||||
# MPV Plugin
|
||||
|
||||
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. SubMiner-managed launches inject the bundled runtime plugin, so users do not need to install it into mpv's global `scripts` directory.
|
||||
**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.
|
||||
|
||||
**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 ships as a modular Lua package under `plugin/subminer/` (entry point `init.lua`, which loads `main.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
|
||||
|
||||
## Runtime Loading
|
||||
|
||||
@@ -23,22 +27,45 @@ input-ipc-server=\\.\pipe\subminer-socket
|
||||
|
||||
## Keybindings
|
||||
|
||||
All keybindings use a `y` chord prefix — press `y`, then the second key:
|
||||
Most plugin actions use a `y` chord prefix — press `y`, then the second key (a "chord"):
|
||||
|
||||
| Chord | Action |
|
||||
| ----- | ---------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-k` | Skip intro (AniSkip) |
|
||||
| Chord | Action |
|
||||
| ---------------- | -------------------------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-h` | Open session help / keybinding modal |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
|
||||
The AniSkip key is **not** a `y` chord. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it.
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
How it works:
|
||||
|
||||
1. The SubMiner app compiles your configured shortcuts, custom keybindings, and stats keys into a normalized list and writes it to `session-bindings.json` in the SubMiner config directory.
|
||||
2. On load, the plugin reads that file and registers each entry as a forced mpv key binding, translating each accelerator into the matching mpv key name.
|
||||
3. When a binding fires, the plugin either runs a SubMiner action (by invoking the SubMiner binary with the corresponding CLI flag, e.g. `--mine-sentence`) or runs a raw mpv command, depending on what the shortcut maps to.
|
||||
|
||||
Because the bindings come from the same configuration the overlay uses, you maintain one set of shortcuts for both surfaces.
|
||||
|
||||
Live updates: changing a shortcut in the app rewrites `session-bindings.json` and sends the plugin a `subminer-reload-session-bindings` script message, so mpv re-registers the bindings immediately — no mpv restart required.
|
||||
|
||||
Notes:
|
||||
|
||||
- Accelerators are normalized per platform — `CommandOrControl` resolves to `Cmd` on macOS and `Ctrl` elsewhere.
|
||||
- Multi-line actions (`copySubtitleMultiple`, `mineSentenceMultiple`) register temporary `1`–`9` digit follow-up bindings after the trigger key, with `Esc` to cancel.
|
||||
- If two shortcuts compile to the same key, or an accelerator can't be mapped to an mpv key, the app logs a warning and skips that binding instead of registering a broken one.
|
||||
|
||||
## Menu
|
||||
|
||||
Press `y-y` to open an interactive menu in mpv's OSD:
|
||||
@@ -55,93 +82,6 @@ SubMiner:
|
||||
|
||||
Select an item by pressing its number.
|
||||
|
||||
## Configuration
|
||||
|
||||
For advanced/manual runtime use, create or edit `~/.config/mpv/script-opts/subminer.conf`:
|
||||
|
||||
```ini
|
||||
# Path to SubMiner binary. Leave empty for auto-detection.
|
||||
binary_path=
|
||||
|
||||
# MPV IPC socket path. Must match input-ipc-server in mpv.conf.
|
||||
socket_path=/tmp/subminer-socket
|
||||
|
||||
# Enable the texthooker WebSocket server.
|
||||
texthooker_enabled=yes
|
||||
|
||||
# Port for the texthooker server.
|
||||
texthooker_port=5174
|
||||
|
||||
# Window manager backend: auto, hyprland, sway, x11, macos, windows.
|
||||
backend=auto
|
||||
|
||||
# Start the overlay automatically when a file is loaded.
|
||||
# Runs only when mpv input-ipc-server matches socket_path.
|
||||
auto_start=yes
|
||||
|
||||
# Show the visible overlay on auto-start.
|
||||
# Runs only when mpv input-ipc-server matches socket_path.
|
||||
auto_start_visible_overlay=yes
|
||||
|
||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
||||
auto_start_pause_until_ready=yes
|
||||
|
||||
# Show OSD messages for overlay status changes.
|
||||
osd_messages=yes
|
||||
|
||||
# Logging level: debug, info, warn, error.
|
||||
log_level=info
|
||||
|
||||
# Enable AniSkip intro detection/markers.
|
||||
aniskip_enabled=yes
|
||||
|
||||
# Optional title override (launcher fills from guessit when available).
|
||||
aniskip_title=
|
||||
|
||||
# Optional season override (launcher fills from guessit when available).
|
||||
aniskip_season=
|
||||
|
||||
# Optional MAL ID override. Leave blank to resolve from media title.
|
||||
aniskip_mal_id=
|
||||
|
||||
# Optional episode override. Leave blank to detect from filename/title.
|
||||
aniskip_episode=
|
||||
|
||||
# Show OSD skip button while inside intro range.
|
||||
aniskip_show_button=yes
|
||||
|
||||
# OSD label + keybinding for intro skip action.
|
||||
aniskip_button_text=You can skip by pressing %s
|
||||
aniskip_button_key=y-k
|
||||
aniskip_button_duration=3
|
||||
```
|
||||
|
||||
### Option Reference
|
||||
|
||||
| Option | Default | Values | Description |
|
||||
| ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
|
||||
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
||||
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
||||
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
||||
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
||||
|
||||
## Binary Auto-Detection
|
||||
|
||||
When `binary_path` is empty, the plugin searches platform-specific locations:
|
||||
@@ -218,7 +158,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing TAB` by default; the key reflects `mpv.aniskipButtonKey`).
|
||||
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
@@ -488,6 +488,7 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -507,11 +508,14 @@
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"imageMaxWidth": 0, // Maximum width for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"imageMaxHeight": 0, // Maximum height for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
@@ -555,6 +559,8 @@
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
+12
-4
@@ -1,5 +1,13 @@
|
||||
# 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.
|
||||
|
||||
A few terms used throughout:
|
||||
|
||||
- **Overlay** — the transparent SubMiner window that sits on top of mpv and shows the interactive subtitles. Most shortcuts only work while this window has focus (click the video once if a shortcut seems to do nothing).
|
||||
- **`Ctrl/Cmd`** — use `Ctrl` on Windows/Linux and `Cmd` (⌘) on macOS. In the config file this is written as `CommandOrControl`.
|
||||
- **Accelerator** — Electron's name for a shortcut string like `Alt+Shift+O`.
|
||||
|
||||
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
|
||||
|
||||
## Global Shortcuts
|
||||
@@ -29,7 +37,7 @@ These work when the overlay window has focus.
|
||||
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
|
||||
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
|
||||
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine.
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. 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
|
||||
|
||||
@@ -39,7 +47,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| `V` | Toggle primary subtitle bar visibility |
|
||||
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
@@ -104,13 +112,13 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
|
||||
|
||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
||||
|
||||
|
||||
@@ -73,9 +73,11 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | 5 colors[^1] | Array of five hex colors for banded mode |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Custom path to frequency dictionary root (empty = auto-discover) |
|
||||
|
||||
[^1]: Default banded palette (most common → least common): `#ed8796`, `#f5a97f`, `#f9e2af`, `#8bd5ca`, `#8aadf4`.
|
||||
|
||||
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
|
||||
|
||||
@@ -102,7 +104,7 @@ SubMiner loads offline `term_meta_bank_*.json` files from `vendor/yomitan-jlpt-v
|
||||
| N1 | `#ed8796` | Red |
|
||||
| N2 | `#f5a97f` | Peach |
|
||||
| N3 | `#f9e2af` | Yellow |
|
||||
| N4 | `#a6e3a1` | Green |
|
||||
| N4 | `#8bd5ca` | Teal |
|
||||
| N5 | `#8aadf4` | Blue |
|
||||
|
||||
All colors are customizable via the `subtitleStyle.jlptColors` object.
|
||||
|
||||
@@ -51,14 +51,14 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
||||
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
||||
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
||||
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
||||
| `backgroundColor` | string | — | Sidebar shell background color |
|
||||
| `textColor` | string | — | Default cue text color |
|
||||
| `fontFamily` | string | — | CSS `font-family` applied to cue text |
|
||||
| `backgroundColor` | string | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
|
||||
| `textColor` | string | `#cad3f5` | Default cue text color |
|
||||
| `fontFamily` | string | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | CSS `font-family` applied to cue text |
|
||||
| `fontSize` | number | `16` | Base cue font size in CSS pixels |
|
||||
| `timestampColor` | string | — | Cue timestamp color |
|
||||
| `activeLineColor` | string | — | Active cue text color |
|
||||
| `activeLineBackgroundColor` | string | — | Active cue background color |
|
||||
| `hoverLineBackgroundColor` | string | — | Hovered cue background color |
|
||||
| `timestampColor` | string | `#a5adcb` | Cue timestamp color |
|
||||
| `activeLineColor` | string | `#f5bde6` | Active cue text color |
|
||||
| `activeLineBackgroundColor` | string | `rgba(138, 173, 244, 0.22)` | Active cue background color |
|
||||
| `hoverLineBackgroundColor` | string | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
|
||||
|
||||
## Keyboard Shortcut
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Troubleshooting
|
||||
|
||||
Common issues and how to resolve them.
|
||||
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.
|
||||
|
||||
## MPV Connection
|
||||
|
||||
@@ -9,7 +9,7 @@ Common issues and how to resolve them.
|
||||
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.
|
||||
|
||||
- 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 (`mpvSocketPath`).
|
||||
- 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.
|
||||
|
||||
SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart.
|
||||
@@ -315,6 +315,7 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
||||
|
||||
- **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 are not supported — both mpv and SubMiner must run under X11 or Xwayland instead.
|
||||
- **X11 / Xwayland**: Requires `xdotool` 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 to work.
|
||||
- **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, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath.
|
||||
|
||||
### Hyprland
|
||||
|
||||
+9
-8
@@ -38,13 +38,13 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
|
||||
|
||||
## How It Works
|
||||
|
||||
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`
|
||||
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
|
||||
4. Subtitles are tokenized with Yomitan's internal parser
|
||||
5. Words are displayed as interactive spans in the overlay
|
||||
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
|
||||
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
|
||||
|
||||
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
|
||||
|
||||
@@ -156,6 +156,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||
Override with e.g. `--password-store=basic_text`.
|
||||
@@ -190,7 +191,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). 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.
|
||||
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||
|
||||
@@ -241,7 +242,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
|
||||
|
||||
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
||||
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p <profile> ...`):
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches. The `subminer` wrapper passes no mpv profile by default; set one with `subminer -p <profile> ...` or with `mpv.profile` in your config (for example `"profile": "subminer"` to use the `[subminer]` profile below):
|
||||
|
||||
```ini
|
||||
[subminer]
|
||||
@@ -283,7 +284,7 @@ Notes:
|
||||
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
|
||||
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
|
||||
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants).
|
||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# WebSocket / Texthooker API & Integration
|
||||
|
||||
**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 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.
|
||||
@@ -46,7 +50,7 @@ SubMiner's integration ports are configured in `config.jsonc`.
|
||||
- `texthooker.launchAtStartup` starts the local HTTP UI automatically.
|
||||
- `texthooker.openBrowser` controls whether SubMiner opens the texthooker page in your browser when it starts.
|
||||
|
||||
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process and override the texthooker port in `subminer.conf`.
|
||||
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
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
||||
|
||||
## Requirements
|
||||
|
||||
- **yt-dlp** must be installed and on `PATH` (or set `SUBMINER_YTDLP_BIN` to its path)
|
||||
- mpv with `--input-ipc-server` configured (handled automatically by the `subminer` launcher)
|
||||
- **[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
|
||||
|
||||
@@ -111,8 +111,8 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
```jsonc
|
||||
{
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": ["eng", "en"],
|
||||
"autoLoadSecondarySub": true,
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,8 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Language codes for secondary subtitle auto-loading (default: English) |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load matching secondary track |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
+1
-2
@@ -3,7 +3,7 @@
|
||||
# SubMiner Internal Docs
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need internal architecture, workflow, verification, or release guidance
|
||||
|
||||
@@ -15,7 +15,6 @@ Read when: you need internal architecture, workflow, verification, or release gu
|
||||
- [Workflow](./workflow/README.md) - planning, execution, verification expectations
|
||||
- [Knowledge Base](./knowledge-base/README.md) - how docs are structured, maintained, and audited
|
||||
- [Release Guide](./RELEASING.md) - tagged release checklist
|
||||
- [Plans](./plans/) - active design and implementation artifacts
|
||||
|
||||
## Fast Paths
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Architecture Map
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: runtime ownership, composition boundaries, or layering questions
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -19,7 +19,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Config system: `src/config/`
|
||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
@@ -37,6 +37,8 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Anki-specific contracts: `src/types/anki.ts`
|
||||
- External integration contracts: `src/types/integrations.ts`
|
||||
- Runtime-option contracts: `src/types/runtime-options.ts`
|
||||
- Settings UI contracts: `src/types/settings.ts`
|
||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||
- Compatibility-only barrel: `src/types.ts`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Layering Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: deciding whether a dependency direction is acceptable
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Knowledge Base Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: maintaining the internal doc system itself
|
||||
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
# Documentation Catalog
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: finding internal docs or checking verification status
|
||||
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| KB home | `docs/README.md` | active | 2026-03-13 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-03-13 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-03-13 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-03-13 | dependency direction and smells |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-03-13 | maintenance policy |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-03-13 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-03-13 | lightweight vs execution plans |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-03-13 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-03-13 | release checklist |
|
||||
| Active plans | `docs/plans/` | active | 2026-03-13 | task-scoped design and implementation artifacts |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
|
||||
## Update Rules
|
||||
|
||||
|
||||
@@ -37,4 +37,3 @@ Grades are directional, not ceremonial. The point is to keep gaps visible.
|
||||
|
||||
- Some deep architecture detail still lives in `docs-site/architecture.md` and may merit later migration.
|
||||
- Quality grading is manual and should be refreshed when major refactors land.
|
||||
- Active plans can accumulate without lifecycle cleanup if humans do not prune them.
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Config Settings Window
|
||||
|
||||
read_when: changing config UI, config save behavior, or config docs
|
||||
|
||||
## Intent
|
||||
|
||||
Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`.
|
||||
|
||||
The UI groups options by workflow:
|
||||
|
||||
- Viewing
|
||||
- Mining & Anki
|
||||
- Playback & Sources
|
||||
- Input
|
||||
- Integrations
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections.
|
||||
|
||||
## Sources
|
||||
|
||||
- Canonical defaults: `DEFAULT_CONFIG`
|
||||
- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY`
|
||||
- UI registry: `src/config/settings/registry.ts`
|
||||
- JSONC save path: `src/config/settings/jsonc-edit.ts`
|
||||
- Window runtime: `src/main/runtime/config-settings-window.ts`
|
||||
|
||||
## Save Contract
|
||||
|
||||
Settings writes use `jsonc-parser.modify`, not `JSON.stringify`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys.
|
||||
- Reset removes the explicit path so defaults resolve normally.
|
||||
- Validate the candidate config before writing.
|
||||
- Reject warnings caused by modified fields.
|
||||
- Preserve unrelated existing warnings and return them in the snapshot.
|
||||
- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported.
|
||||
- Never return secret values to the renderer; snapshots only expose configured/not-configured state.
|
||||
|
||||
## Hidden Compatibility Keys
|
||||
|
||||
Do not expose these as first-class controls:
|
||||
|
||||
- `ankiConnect.deck`
|
||||
- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases
|
||||
- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords`
|
||||
- Deprecated Lapis sentence-card fields
|
||||
- `youtubeSubgen.primarySubLanguages`
|
||||
- `anilist.characterDictionary.refreshTtlHours`
|
||||
- `anilist.characterDictionary.evictionPolicy`
|
||||
- `jellyfin.accessToken`
|
||||
- `jellyfin.userId`
|
||||
- `controller.buttonIndices` as a normal editable field
|
||||
|
||||
## Verification
|
||||
|
||||
Minimum targeted checks:
|
||||
|
||||
- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts`
|
||||
- `bun run test:config`
|
||||
- `bun run typecheck`
|
||||
- `bun run build`
|
||||
|
||||
If docs changed:
|
||||
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
@@ -3,7 +3,7 @@
|
||||
# Workflow
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: planning or executing nontrivial work in this repo
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Agent Plugins
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: packaging or migrating repo-local agent workflow skills into plugins
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Planning
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: the task spans multiple files, subsystems, or verification lanes
|
||||
|
||||
@@ -28,9 +28,9 @@ Read when: the task spans multiple files, subsystems, or verification lanes
|
||||
|
||||
## Plan Location
|
||||
|
||||
- active design and implementation docs live in `docs/plans/`
|
||||
- keep names date-prefixed and task-specific
|
||||
- remove or archive old plans deliberately; do not leave mystery artifacts
|
||||
- plans are task-scoped scratch artifacts; keep them with the work (worktree, branch, or PR description), not committed under `docs/`
|
||||
- if a plan must be shared, keep names date-prefixed and task-specific
|
||||
- delete plans once the work lands; do not leave mystery artifacts behind
|
||||
|
||||
## Plan Contents
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
|
||||
@@ -148,6 +148,50 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
||||
const context = createContext();
|
||||
context.pluginRuntimeConfig = {
|
||||
...context.pluginRuntimeConfig,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
|
||||
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
|
||||
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
|
||||
| undefined;
|
||||
assert.equal(runtimeConfig?.autoStart, false);
|
||||
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
|
||||
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
|
||||
@@ -258,6 +258,13 @@ export async function runPlaybackCommandWithDeps(
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...effectivePluginRuntimeConfig,
|
||||
...(isAppOwnedYoutubeFlow
|
||||
? {
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
}
|
||||
: {}),
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
|
||||
@@ -134,7 +134,10 @@ function M.create(ctx)
|
||||
elseif action_id == "copySubtitle" then
|
||||
return { "--copy-subtitle" }
|
||||
elseif action_id == "copySubtitleMultiple" then
|
||||
return { "--copy-subtitle-count", tostring(payload and payload.count or 1) }
|
||||
if payload and payload.count then
|
||||
return { "--copy-subtitle-count", tostring(payload.count) }
|
||||
end
|
||||
return { "--copy-subtitle-multiple" }
|
||||
elseif action_id == "updateLastCardFromClipboard" then
|
||||
return { "--update-last-card-from-clipboard" }
|
||||
elseif action_id == "triggerFieldGrouping" then
|
||||
@@ -144,7 +147,10 @@ function M.create(ctx)
|
||||
elseif action_id == "mineSentence" then
|
||||
return { "--mine-sentence" }
|
||||
elseif action_id == "mineSentenceMultiple" then
|
||||
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
|
||||
if payload and payload.count then
|
||||
return { "--mine-sentence-count", tostring(payload.count) }
|
||||
end
|
||||
return { "--mine-sentence-multiple" }
|
||||
elseif action_id == "toggleSecondarySub" then
|
||||
return { "--toggle-secondary-sub" }
|
||||
elseif action_id == "toggleSubtitleSidebar" then
|
||||
@@ -232,73 +238,6 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
local function clear_numeric_selection(show_cancelled)
|
||||
if state.session_numeric_selection and state.session_numeric_selection.timeout then
|
||||
state.session_numeric_selection.timeout:kill()
|
||||
end
|
||||
state.session_numeric_selection = nil
|
||||
remove_binding_names(state.session_numeric_binding_names)
|
||||
if show_cancelled then
|
||||
show_osd("Cancelled")
|
||||
end
|
||||
end
|
||||
|
||||
local function build_modifier_prefixes(modifiers)
|
||||
local prefixes = { "" }
|
||||
if type(modifiers) ~= "table" then
|
||||
return prefixes
|
||||
end
|
||||
|
||||
for _, modifier in ipairs(modifiers) do
|
||||
local mapped = MODIFIER_MAP[modifier]
|
||||
if mapped then
|
||||
local existing_count = #prefixes
|
||||
for index = 1, existing_count do
|
||||
prefixes[#prefixes + 1] = prefixes[index] .. mapped .. "+"
|
||||
end
|
||||
end
|
||||
end
|
||||
return prefixes
|
||||
end
|
||||
|
||||
local function start_numeric_selection(action_id, timeout_ms, starter_modifiers)
|
||||
clear_numeric_selection(false)
|
||||
local modifier_prefixes = build_modifier_prefixes(starter_modifiers)
|
||||
for digit = 1, 9 do
|
||||
local digit_string = tostring(digit)
|
||||
for _, prefix in ipairs(modifier_prefixes) do
|
||||
local key_name = prefix .. digit_string
|
||||
local modifier_name = prefix:gsub("[^%w]", "-")
|
||||
local name = "subminer-session-digit-" .. modifier_name .. digit_string
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
clear_numeric_selection(false)
|
||||
invoke_cli_action(action_id, { count = digit })
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
|
||||
"subminer-session-digit-cancel"
|
||||
mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function()
|
||||
clear_numeric_selection(true)
|
||||
end)
|
||||
|
||||
state.session_numeric_selection = {
|
||||
action_id = action_id,
|
||||
timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function()
|
||||
clear_numeric_selection(false)
|
||||
show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout")
|
||||
end),
|
||||
}
|
||||
|
||||
show_osd(
|
||||
action_id == "copySubtitleMultiple"
|
||||
and "Copy how many lines? Press 1-9 (Esc to cancel)"
|
||||
or "Mine how many lines? Press 1-9 (Esc to cancel)"
|
||||
)
|
||||
end
|
||||
|
||||
local function execute_mpv_command(command)
|
||||
if type(command) ~= "table" or command[1] == nil then
|
||||
return
|
||||
@@ -306,17 +245,12 @@ function M.create(ctx)
|
||||
mp.commandv(unpack_fn(command))
|
||||
end
|
||||
|
||||
local function handle_binding(binding, numeric_selection_timeout_ms)
|
||||
local function handle_binding(binding)
|
||||
if binding.actionType == "mpv-command" then
|
||||
execute_mpv_command(binding.command)
|
||||
return
|
||||
end
|
||||
|
||||
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
|
||||
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
|
||||
return
|
||||
end
|
||||
|
||||
invoke_cli_action(binding.actionId, binding.payload)
|
||||
end
|
||||
|
||||
@@ -339,7 +273,6 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function clear_bindings()
|
||||
clear_numeric_selection(false)
|
||||
remove_binding_names(state.session_binding_names)
|
||||
end
|
||||
|
||||
@@ -350,21 +283,18 @@ function M.create(ctx)
|
||||
return false
|
||||
end
|
||||
|
||||
clear_numeric_selection(false)
|
||||
|
||||
local previous_binding_names = state.session_binding_names
|
||||
local next_binding_names = {}
|
||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||
local generation = state.session_binding_generation
|
||||
|
||||
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
local key_name = key_spec_to_mpv_binding(binding.key)
|
||||
if key_name then
|
||||
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding, timeout_ms)
|
||||
handle_binding(binding)
|
||||
end)
|
||||
else
|
||||
subminer_log(
|
||||
|
||||
@@ -351,21 +351,10 @@ assert_true(
|
||||
|
||||
starter.fn()
|
||||
|
||||
local modified_digit = nil
|
||||
for _, binding in ipairs(recorded.bindings) do
|
||||
if binding.keys == "Ctrl+Shift+3" then
|
||||
modified_digit = binding
|
||||
break
|
||||
end
|
||||
end
|
||||
assert_true(modified_digit ~= nil, "numeric selection should bind Ctrl+Shift+3")
|
||||
|
||||
modified_digit.fn()
|
||||
|
||||
local call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(call ~= nil, "modified digit should invoke CLI action")
|
||||
assert_true(call ~= nil, "multi-line shortcut should invoke CLI action")
|
||||
assert_true(call[1] == "/tmp/subminer", "CLI action should use configured binary")
|
||||
assert_true(call[2] == "--mine-sentence-count", "CLI action should mine sentence count")
|
||||
assert_true(call[3] == "3", "CLI action should pass selected count")
|
||||
assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine sentence count selector")
|
||||
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||
|
||||
print("plugin session binding regression tests: OK")
|
||||
|
||||
@@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
|
||||
test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => {
|
||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||
config: {
|
||||
fields: {
|
||||
@@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(leadInSeconds, 1.75);
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
|
||||
|
||||
@@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
|
||||
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
|
||||
}
|
||||
|
||||
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
|
||||
const configuredPadding = config.media?.audioPadding;
|
||||
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
|
||||
return configuredPadding;
|
||||
}
|
||||
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
|
||||
}
|
||||
|
||||
export async function probeAudioDurationSeconds(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
@@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
||||
totalLeadInSeconds += durationSeconds;
|
||||
}
|
||||
|
||||
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
|
||||
return totalLeadInSeconds;
|
||||
}
|
||||
|
||||
@@ -175,3 +175,99 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||
assert.equal(mergeCalls.length, 0);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const { service, updatedFields, storedMedia } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageFormat: 'jpg',
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: false,
|
||||
overwriteImage: false,
|
||||
},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getTimingTracker: () =>
|
||||
({
|
||||
findTiming: (text: string) => {
|
||||
if (text === '一行目') return { startTime: 10, endTime: 12 };
|
||||
if (text === '二行目') return { startTime: 12.5, endTime: 14 };
|
||||
return null;
|
||||
},
|
||||
}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'stream-open-filename');
|
||||
return edlSource;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
|
||||
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
@@ -237,14 +237,19 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
@@ -271,12 +276,14 @@ export class CardCreationService {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(
|
||||
mpvClient.currentVideoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
animatedLeadInSeconds,
|
||||
);
|
||||
const imageBuffer = videoPath
|
||||
? await this.generateImageBuffer(
|
||||
videoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
animatedLeadInSeconds,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
|
||||
@@ -61,6 +61,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.texthooker.launchAtStartup, false);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||
assert.equal(config.anilist.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||
|
||||
@@ -24,6 +24,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
tags: ['SubMiner'],
|
||||
deck: '',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
@@ -43,14 +44,14 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
imageType: 'static',
|
||||
imageFormat: 'jpg',
|
||||
imageQuality: 92,
|
||||
imageMaxWidth: undefined,
|
||||
imageMaxHeight: undefined,
|
||||
imageMaxWidth: 0,
|
||||
imageMaxHeight: 0,
|
||||
animatedFps: 10,
|
||||
animatedMaxWidth: 640,
|
||||
animatedMaxHeight: undefined,
|
||||
animatedMaxHeight: 0,
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
audioPadding: 0.5,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
@@ -88,6 +89,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
jimaku: {
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
apiKey: '',
|
||||
apiKeyCommand: '',
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
|
||||
@@ -58,6 +58,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.deck',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.deck,
|
||||
description:
|
||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.word',
|
||||
kind: 'string',
|
||||
@@ -200,14 +207,14 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth,
|
||||
description:
|
||||
'Optional maximum width for static images. Leave unset to preserve the source resolution.',
|
||||
'Maximum width for static images, in pixels. Set to 0 to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for static images. Leave unset to preserve the source resolution.',
|
||||
'Maximum height for static images, in pixels. Set to 0 to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedFps',
|
||||
@@ -226,7 +233,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.',
|
||||
'Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedCrf',
|
||||
@@ -344,6 +351,20 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jimaku.apiBaseUrl,
|
||||
description: 'Base URL of the Jimaku subtitle search API.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiKey,
|
||||
description:
|
||||
'Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiKeyCommand',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiKeyCommand,
|
||||
description:
|
||||
'Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.languagePreference',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
handleMultiCopyDigit,
|
||||
mineSentenceCard,
|
||||
} from './mining';
|
||||
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
|
||||
|
||||
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
|
||||
const osd: string[] = [];
|
||||
@@ -207,3 +208,76 @@ test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
const created: Array<{ sentence: string; startTime: number; endTime: number }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('same', 1, 2);
|
||||
tracker.recordSubtitle('other', 3, 4);
|
||||
tracker.recordSubtitle('same', 5, 6);
|
||||
|
||||
handleMineSentenceDigit(3, {
|
||||
subtitleTimingTracker: tracker,
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime) => {
|
||||
created.push({ sentence, startTime, endTime });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => undefined,
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(created, [{ sentence: 'same other same', startTime: 1, endTime: 6 }]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
const recordSubtitleWithSecondary = tracker.recordSubtitle as (
|
||||
text: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
secondaryText?: string,
|
||||
) => void;
|
||||
|
||||
try {
|
||||
recordSubtitleWithSecondary.call(tracker, 'one', 1, 2, 'translation one');
|
||||
recordSubtitleWithSecondary.call(tracker, 'two', 3, 4, 'translation two');
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: tracker,
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'current translation only',
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{ sentence: 'one two', secondarySub: 'translation one translation two' },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||
|
||||
interface SubtitleTimingTrackerLike {
|
||||
getRecentBlocks: (count: number) => string[];
|
||||
getRecentEntries?: (count: number) => SubtitleTimingBlock[];
|
||||
getCurrentSubtitle: () => string | null;
|
||||
findTiming: (text: string) => { startTime: number; endTime: number } | null;
|
||||
}
|
||||
@@ -79,6 +82,19 @@ function requireAnkiIntegration(
|
||||
return ankiIntegration;
|
||||
}
|
||||
|
||||
function getSecondarySubTextForMinedBlocks(
|
||||
entries: SubtitleTimingBlock[] | undefined,
|
||||
getCurrentSecondarySubText: () => string | undefined,
|
||||
): string | undefined {
|
||||
const secondaryBlocks = entries
|
||||
?.map((entry) => entry.secondaryText?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
if (secondaryBlocks && secondaryBlocks.length > 0) {
|
||||
return secondaryBlocks.join(' ');
|
||||
}
|
||||
return getCurrentSecondarySubText();
|
||||
}
|
||||
|
||||
export async function updateLastCardFromClipboard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
readClipboardText: () => string;
|
||||
@@ -146,17 +162,20 @@ export function handleMineSentenceDigit(
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
|
||||
const blocks = deps.subtitleTimingTracker.getRecentBlocks(count);
|
||||
const entries = deps.subtitleTimingTracker.getRecentEntries?.(count);
|
||||
const blocks =
|
||||
entries?.map((entry) => entry.displayText) ?? deps.subtitleTimingTracker.getRecentBlocks(count);
|
||||
if (blocks.length === 0) {
|
||||
deps.showMpvOsd('No subtitle history available');
|
||||
return;
|
||||
}
|
||||
|
||||
const timings: { startTime: number; endTime: number }[] = [];
|
||||
for (const block of blocks) {
|
||||
const timing = deps.subtitleTimingTracker.findTiming(block);
|
||||
if (timing) timings.push(timing);
|
||||
}
|
||||
const timings: { startTime: number; endTime: number }[] =
|
||||
entries ??
|
||||
blocks.flatMap((block) => {
|
||||
const timing = deps.subtitleTimingTracker?.findTiming(block);
|
||||
return timing ? [timing] : [];
|
||||
});
|
||||
|
||||
if (timings.length === 0) {
|
||||
deps.showMpvOsd('Subtitle timing not found');
|
||||
@@ -166,9 +185,13 @@ export function handleMineSentenceDigit(
|
||||
const rangeStart = Math.min(...timings.map((t) => t.startTime));
|
||||
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
|
||||
const sentence = blocks.join(' ');
|
||||
const secondarySubText = getSecondarySubTextForMinedBlocks(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
const cardsToMine = 1;
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
|
||||
@@ -843,7 +843,7 @@ export function createStatsApp(
|
||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||
const mediaGen = new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0.5;
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
|
||||
+90
-9
@@ -464,6 +464,7 @@ import {
|
||||
composeStartupLifecycleHandlers,
|
||||
} from './main/runtime/composers';
|
||||
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
|
||||
import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection';
|
||||
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
||||
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
||||
import {
|
||||
@@ -547,7 +548,12 @@ import {
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
@@ -988,8 +994,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
refreshCurrentSubtitle: (text: string) => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
refreshSubtitleSidebarSource: async (sourcePath: string) => {
|
||||
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath);
|
||||
refreshSubtitleSidebarSource: async (sourcePath: string, mediaPath?: string) => {
|
||||
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {
|
||||
await startTokenizationWarmups();
|
||||
@@ -1076,9 +1082,18 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||
notifyPrimarySubtitleLoaded: () =>
|
||||
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
||||
warn: (message: string) => logger.warn(message),
|
||||
log: (message: string) => logger.info(message),
|
||||
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
||||
createSubtitleTempDir: () =>
|
||||
fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-youtube-subtitles-')),
|
||||
cleanupSubtitleTempDirs: (dirs) => {
|
||||
for (const dir of dirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
@@ -1545,6 +1560,20 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
|
||||
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
|
||||
getCurrentSubtitleState: async () => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
return null;
|
||||
}
|
||||
const [sid, trackList] = await Promise.all([
|
||||
client.requestProperty('sid').catch(() => null),
|
||||
client.requestProperty('track-list').catch(() => null),
|
||||
]);
|
||||
return {
|
||||
sid,
|
||||
trackList: Array.isArray(trackList) ? trackList : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isYoutubePlaybackActiveNow(): boolean {
|
||||
@@ -1745,6 +1774,9 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||
appState.activeParsedSubtitleCues = cues ?? [];
|
||||
appState.activeParsedSubtitleSource = sourceKey;
|
||||
if (!cues?.length) {
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
const mediaPath = getCurrentAutoplayMediaPath();
|
||||
if (mediaPath && cues?.length) {
|
||||
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
|
||||
@@ -1763,11 +1795,15 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||
});
|
||||
|
||||
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
|
||||
async function refreshSubtitleSidebarFromSource(
|
||||
sourcePath: string,
|
||||
mediaPath?: string,
|
||||
): Promise<void> {
|
||||
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||
if (!normalizedSourcePath) {
|
||||
return;
|
||||
}
|
||||
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
normalizedSourcePath,
|
||||
lastObservedTimePos,
|
||||
@@ -1778,6 +1814,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
|
||||
subtitlePrefetchInitController,
|
||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
});
|
||||
@@ -1792,8 +1829,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
const subtitlePrefetchRuntime = {
|
||||
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
||||
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
|
||||
refreshSubtitleSidebarFromSource(sourcePath),
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) =>
|
||||
refreshSubtitleSidebarFromSource(sourcePath, mediaPath),
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
||||
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
||||
@@ -3632,6 +3669,7 @@ const {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
appState.discordPresenceService = null;
|
||||
@@ -4271,6 +4309,10 @@ const {
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
|
||||
normalizedPath,
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
);
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
|
||||
@@ -4284,8 +4326,11 @@ const {
|
||||
appState.currentSubText = '';
|
||||
appState.currentSubAssText = '';
|
||||
appState.currentSubtitleData = null;
|
||||
appState.activeParsedSubtitleCues = [];
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
if (!preserveParsedSubtitleCues) {
|
||||
appState.activeParsedSubtitleCues = [];
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
@@ -4295,7 +4340,9 @@ const {
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
startupOsdSequencer.reset();
|
||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
if (!preserveParsedSubtitleCues) {
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
}
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -4844,6 +4891,20 @@ const {
|
||||
numericSessions: {
|
||||
onMultiCopyDigit: (count) => handleMultiCopyDigit(count),
|
||||
onMineSentenceDigit: (count) => handleMineSentenceDigit(count),
|
||||
tryBeginMultiCopyOverlaySelection: (timeoutMs) =>
|
||||
tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'copySubtitleMultiple',
|
||||
timeoutMs,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
}),
|
||||
tryBeginMineSentenceOverlaySelection: (timeoutMs) =>
|
||||
tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'mineSentenceMultiple',
|
||||
timeoutMs,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
}),
|
||||
},
|
||||
overlayShortcutsRuntimeMainDeps: {
|
||||
overlayShortcutsRuntime,
|
||||
@@ -5555,6 +5616,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
config,
|
||||
};
|
||||
}
|
||||
if (
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath,
|
||||
cachedMediaPath: appState.activeParsedSubtitleMediaPath,
|
||||
cachedCueCount: appState.activeParsedSubtitleCues.length,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
||||
currentExternalFilenameRaw,
|
||||
@@ -5586,6 +5661,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const cues = parseSubtitleCues(content, resolvedSource.path);
|
||||
appState.activeParsedSubtitleCues = cues;
|
||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||
return {
|
||||
cues,
|
||||
currentTimeSec,
|
||||
@@ -5793,6 +5869,11 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||
logInfo: (message: string) => logger.info(message),
|
||||
},
|
||||
ensureTrayForCommand: (args) => {
|
||||
if (args.background || args.managedPlayback) {
|
||||
ensureTray();
|
||||
}
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ test('manual watched session action starts immersion tracker before marking watc
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes clear rendered subtitle state', () => {
|
||||
test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
|
||||
@@ -31,8 +31,11 @@ test('media path changes clear rendered subtitle state', () => {
|
||||
assert.match(actionBlock, /appState\.currentSubText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
|
||||
assert.match(actionBlock, /isSameYoutubeMediaPath\(/);
|
||||
assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/);
|
||||
assert.match(actionBlock, /lastObservedTimePos = 0;/);
|
||||
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
|
||||
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
|
||||
@@ -52,3 +55,19 @@ test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
|
||||
const source = readMainSource();
|
||||
const snapshotBlock = source.match(
|
||||
/getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(snapshotBlock);
|
||||
assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/);
|
||||
assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/);
|
||||
assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/);
|
||||
assert.ok(
|
||||
snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') <
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,15 +40,17 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,3 +83,39 @@ test('cli command runtime handler skips generic overlay prerequisites for youtub
|
||||
|
||||
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
|
||||
});
|
||||
|
||||
test('cli command runtime handler ensures tray for managed playback commands', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createCliCommandRuntimeHandler({
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
ensureTrayForCommand: (args) => {
|
||||
if (args.managedPlayback) {
|
||||
calls.push('ensure-tray');
|
||||
}
|
||||
},
|
||||
createCliCommandContext: () => {
|
||||
calls.push('context');
|
||||
return { id: 'ctx' };
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
|
||||
calls.push(`cli:${source}:${context.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
handler(
|
||||
{
|
||||
managedPlayback: true,
|
||||
youtubePlay: 'https://youtube.com/watch?v=abc',
|
||||
} as never,
|
||||
'second-instance',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['ensure-tray', 'context', 'cli:second-instance:ctx']);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
|
||||
|
||||
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
|
||||
ensureTrayForCommand?: (args: CliArgs, source: CliCommandSource) => void;
|
||||
createCliCommandContext: () => TCliContext;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
@@ -29,6 +30,7 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
) {
|
||||
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
||||
}
|
||||
deps.ensureTrayForCommand?.(args, source);
|
||||
const cliContext = deps.createCliCommandContext();
|
||||
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -9,6 +9,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSecondarySubText: 'secondary',
|
||||
currentTimePos: 12.25,
|
||||
requestProperty: async () => 18.75,
|
||||
},
|
||||
@@ -20,7 +21,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
|
||||
recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) =>
|
||||
calls.push(`timing:${text}:${secondaryText ?? ''}`),
|
||||
},
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
@@ -113,6 +115,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('remote-stopped'));
|
||||
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
||||
assert.ok(calls.includes('anilist-post-watch'));
|
||||
assert.ok(calls.includes('timing:y:secondary'));
|
||||
assert.ok(calls.includes('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordPauseState?: (paused: boolean) => void;
|
||||
} | null;
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
||||
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||
} | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
@@ -132,7 +132,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
deps.appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
),
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
|
||||
@@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers',
|
||||
'mine-sentence:digit:3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('numeric shortcut session runtime handlers prefer overlay digit selection when available', () => {
|
||||
const calls: string[] = [];
|
||||
const createSession = (name: string) => ({
|
||||
start: () => calls.push(`${name}:start`),
|
||||
cancel: () => calls.push(`${name}:cancel`),
|
||||
});
|
||||
|
||||
const runtime = createNumericShortcutSessionRuntimeHandlers({
|
||||
multiCopySession: createSession('multi-copy'),
|
||||
mineSentenceSession: createSession('mine-sentence'),
|
||||
onMultiCopyDigit: () => calls.push('multi-copy:digit'),
|
||||
onMineSentenceDigit: () => calls.push('mine-sentence:digit'),
|
||||
tryBeginMultiCopyOverlaySelection: (timeoutMs) => {
|
||||
calls.push(`multi-copy:overlay:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
tryBeginMineSentenceOverlaySelection: () => false,
|
||||
});
|
||||
|
||||
runtime.startPendingMultiCopy(500);
|
||||
runtime.startPendingMineSentenceMultiple(700);
|
||||
|
||||
assert.deepEqual(calls, ['multi-copy:overlay:500', 'mine-sentence:start']);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
||||
mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
|
||||
onMultiCopyDigit: (count: number) => void;
|
||||
onMineSentenceDigit: (count: number) => void;
|
||||
tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean;
|
||||
tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean;
|
||||
}) {
|
||||
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
|
||||
session: deps.multiCopySession,
|
||||
@@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
||||
|
||||
return {
|
||||
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs),
|
||||
startPendingMultiCopy: (timeoutMs: number) => {
|
||||
if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return;
|
||||
startPendingMultiCopyHandler(timeoutMs);
|
||||
},
|
||||
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
startPendingMineSentenceMultipleHandler(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => {
|
||||
if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return;
|
||||
startPendingMineSentenceMultipleHandler(timeoutMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { tryBeginVisibleOverlayNumericSelection } from './overlay-numeric-selection';
|
||||
|
||||
function createWindowStub(
|
||||
options: {
|
||||
destroyed?: boolean;
|
||||
visible?: boolean;
|
||||
focused?: boolean;
|
||||
webContentsFocused?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
window: {
|
||||
isDestroyed: () => options.destroyed === true,
|
||||
isVisible: () => options.visible !== false,
|
||||
isFocused: () => options.focused === true,
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
calls.push(`mouse:${ignore}`);
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
webContents: {
|
||||
isFocused: () => options.webContentsFocused === true,
|
||||
focus: () => {
|
||||
calls.push('web-focus');
|
||||
},
|
||||
send: (channel: string, payload: unknown) => {
|
||||
calls.push(`send:${channel}:${JSON.stringify(payload)}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('tryBeginVisibleOverlayNumericSelection focuses visible overlay and sends selector event', () => {
|
||||
const { window, calls } = createWindowStub();
|
||||
|
||||
const handled = tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'copySubtitleMultiple',
|
||||
timeoutMs: 1234,
|
||||
getMainWindow: () => window,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [
|
||||
'mouse:false',
|
||||
'focus',
|
||||
'web-focus',
|
||||
`send:${IPC_CHANNELS.event.sessionNumericSelectionStart}:{"actionId":"copySubtitleMultiple","timeoutMs":1234}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('tryBeginVisibleOverlayNumericSelection skips hidden visible overlay', () => {
|
||||
const { window, calls } = createWindowStub({ visible: false });
|
||||
|
||||
const handled = tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'mineSentenceMultiple',
|
||||
timeoutMs: 3000,
|
||||
getMainWindow: () => window,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { SessionNumericSelectionStartPayload } from '../../types/runtime';
|
||||
|
||||
type OverlayNumericSelectionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
isFocused?: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
focus: () => void;
|
||||
webContents: {
|
||||
isFocused?: () => boolean;
|
||||
focus: () => void;
|
||||
send: (channel: string, payload: SessionNumericSelectionStartPayload) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function tryBeginVisibleOverlayNumericSelection(options: {
|
||||
actionId: SessionNumericSelectionStartPayload['actionId'];
|
||||
timeoutMs: number;
|
||||
getMainWindow: () => OverlayNumericSelectionWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
}): boolean {
|
||||
if (!options.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = options.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
if (typeof mainWindow.isFocused !== 'function' || !mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
if (
|
||||
typeof mainWindow.webContents.isFocused !== 'function' ||
|
||||
!mainWindow.webContents.isFocused()
|
||||
) {
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
mainWindow.webContents.send(IPC_CHANNELS.event.sessionNumericSelectionStart, {
|
||||
actionId: options.actionId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime';
|
||||
import {
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from './subtitle-prefetch-runtime';
|
||||
|
||||
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -57,3 +60,43 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
|
||||
cleanup: resolved?.cleanup,
|
||||
});
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime preserves parsed cues when YouTube active track source is unresolved', async () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'path') return 'https://www.youtube.com/watch?v=video123';
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 4,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (name === 'sid') return 4;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {
|
||||
calls.push('cancel');
|
||||
},
|
||||
initSubtitlePrefetch: async () => {
|
||||
calls.push('init');
|
||||
},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => videoPath.includes('youtube.com'),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
@@ -160,6 +161,9 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||
return;
|
||||
}
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,55 @@ test('ensure tray creates new tray and binds click handler', () => {
|
||||
assert.ok(calls.includes('bind-click'));
|
||||
});
|
||||
|
||||
test('ensure tray logs Linux tray registration failures without crashing startup', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = null;
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => null,
|
||||
setTray: (tray) => {
|
||||
trayRef = tray;
|
||||
calls.push('set-tray');
|
||||
},
|
||||
buildTrayMenu: () => ({ id: 'menu' }),
|
||||
resolveTrayIconPath: () => '/tmp/icon.png',
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: () => ({
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}),
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () => {
|
||||
throw new Error('StatusNotifier watcher unavailable');
|
||||
},
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'linux',
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
|
||||
assert.equal(trayRef, null);
|
||||
assert.deepEqual(calls, [
|
||||
'warn:Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. StatusNotifier watcher unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
test('destroy tray handler destroys active tray and clears ref', () => {
|
||||
const calls: string[] = [];
|
||||
let tray: { destroy: () => void } | null = {
|
||||
|
||||
@@ -48,7 +48,20 @@ export function createEnsureTrayHandler(deps: {
|
||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
||||
}
|
||||
|
||||
const tray = deps.createTray(trayIcon);
|
||||
let tray: TrayLike;
|
||||
try {
|
||||
tray = deps.createTray(trayIcon);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
if (deps.platform === 'linux') {
|
||||
deps.logWarn(
|
||||
`Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. ${reason}`,
|
||||
);
|
||||
} else {
|
||||
deps.logWarn(`Unable to create tray icon. ${reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tray.setToolTip(deps.trayTooltip);
|
||||
tray.setContextMenu(deps.buildTrayMenu());
|
||||
tray.on('click', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||
@@ -306,6 +307,7 @@ test('youtube flow reports probe failure through the configured reporter in manu
|
||||
|
||||
test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => {
|
||||
const failures: string[] = [];
|
||||
const loadedSignals: string[] = [];
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -358,6 +360,9 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
reportSubtitleFailure: (message) => {
|
||||
failures.push(message);
|
||||
},
|
||||
notifyPrimarySubtitleLoaded: () => {
|
||||
loadedSignals.push('loaded');
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
@@ -368,6 +373,7 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
assert.deepEqual(loadedSignals, ['loaded']);
|
||||
});
|
||||
|
||||
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
|
||||
@@ -781,11 +787,13 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
|
||||
test('youtube flow injects downloaded primary while reusing existing manual secondary tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -813,7 +821,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.language === 'ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error('should not download secondary track when manual english already exists');
|
||||
},
|
||||
@@ -832,6 +840,13 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
@@ -853,7 +868,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
if (name === 'secondary-sid') {
|
||||
return selectedSecondarySid;
|
||||
}
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -887,6 +902,17 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
'external-filename': null,
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||
@@ -912,24 +938,451 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
|
||||
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-remove'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
|
||||
test('youtube flow injects downloaded primary subtitles instead of reusing streamed youtube tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/manual-ja.ja.vtt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [
|
||||
{
|
||||
...primaryTrack,
|
||||
id: 'manual:ja',
|
||||
sourceLanguage: 'ja',
|
||||
kind: 'manual',
|
||||
title: 'Japanese',
|
||||
},
|
||||
],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
assert.equal(track.id, 'manual:ja');
|
||||
return { path: downloadedPrimaryPath };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: 'manual:ja',
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return downloadedPrimaryAdded
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||
},
|
||||
];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||
refreshedSidebarSources.push(sourcePath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow confirms primary subtitle load before sidebar and tokenization waits', async () => {
|
||||
const events: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/auto-ja-orig.vtt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async () => ({ path: downloadedPrimaryPath }),
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return downloadedPrimaryAdded
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {
|
||||
events.push('sidebar');
|
||||
assert.ok(
|
||||
events.includes('notify'),
|
||||
'primary load should be confirmed before sidebar parsing can delay',
|
||||
);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {
|
||||
events.push('tokenization');
|
||||
assert.ok(
|
||||
events.includes('notify'),
|
||||
'primary load should be confirmed before tokenization waits can delay',
|
||||
);
|
||||
},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
notifyPrimarySubtitleLoaded: () => {
|
||||
events.push('notify');
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.deepEqual(events, ['notify', 'sidebar', 'tokenization']);
|
||||
});
|
||||
|
||||
test('youtube flow downloads subtitles into temporary dirs and exposes cleanup', async () => {
|
||||
const outputDirs: string[] = [];
|
||||
const cleanupCalls: string[][] = [];
|
||||
let tempDirIndex = 0;
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let addedSubtitlePath: string | null = null;
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
|
||||
outputDirs.push(outputDir);
|
||||
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
|
||||
addedSubtitlePath = command[1];
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return addedSubtitlePath
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10 + tempDirIndex,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(addedSubtitlePath),
|
||||
external: true,
|
||||
'external-filename': addedSubtitlePath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp/unused-youtube-cache',
|
||||
createSubtitleTempDir: async () => {
|
||||
tempDirIndex += 1;
|
||||
return `/tmp/subminer-youtube-subtitles-${tempDirIndex}`;
|
||||
},
|
||||
cleanupSubtitleTempDirs: (dirs) => {
|
||||
cleanupCalls.push([...dirs]);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
addedSubtitlePath = null;
|
||||
selectedPrimarySid = null;
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
runtime.cleanupSubtitleTempDirs();
|
||||
runtime.cleanupSubtitleTempDirs();
|
||||
|
||||
assert.deepEqual(outputDirs, [
|
||||
'/tmp/subminer-youtube-subtitles-1',
|
||||
'/tmp/subminer-youtube-subtitles-2',
|
||||
]);
|
||||
assert.deepEqual(cleanupCalls, [
|
||||
['/tmp/subminer-youtube-subtitles-1'],
|
||||
['/tmp/subminer-youtube-subtitles-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube flow falls back to configured output dir when subtitle temp dir creation fails', async () => {
|
||||
const outputDirs: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let addedSubtitlePath: string | null = null;
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
|
||||
outputDirs.push(outputDir);
|
||||
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
|
||||
addedSubtitlePath = command[1];
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return addedSubtitlePath
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(addedSubtitlePath),
|
||||
external: true,
|
||||
'external-filename': addedSubtitlePath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp/youtube-cache',
|
||||
createSubtitleTempDir: async () => {
|
||||
throw new Error('tmp unavailable');
|
||||
},
|
||||
cleanupSubtitleTempDirs: () => {},
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.deepEqual(outputDirs, ['/tmp/youtube-cache']);
|
||||
assert.deepEqual(warnings, [
|
||||
'Failed to create YouTube subtitle temp dir; using configured output dir: tmp unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube flow waits for manual secondary tracks while injecting downloaded primary', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let trackListReads = 0;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -957,7 +1410,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.language === 'ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error('should not download secondary track when manual english appears in mpv');
|
||||
},
|
||||
@@ -976,6 +1429,13 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
@@ -1001,7 +1461,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
if (trackListReads === 1) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -1035,6 +1495,17 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
'external-filename': null,
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
@@ -1057,18 +1528,22 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
|
||||
test('youtube flow injects downloaded primary even when reusable manual youtube tracks exist', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -1098,7 +1573,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.id === 'manual:ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error(
|
||||
'should not download secondary track when existing manual english track is reusable',
|
||||
@@ -1109,6 +1584,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid') {
|
||||
selectedPrimarySid = Number(command[2]);
|
||||
}
|
||||
@@ -1118,7 +1600,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -1144,6 +1626,17 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt',
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
@@ -1181,11 +1674,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
mode: 'download',
|
||||
});
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ type YoutubeFlowDeps = {
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string, mediaPath?: string) => Promise<void>;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
waitForTokenizationReady: () => Promise<void>;
|
||||
waitForAnkiReady: () => Promise<void>;
|
||||
@@ -42,9 +42,12 @@ type YoutubeFlowDeps = {
|
||||
focusOverlayWindow: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
reportSubtitleFailure: (message: string) => void;
|
||||
notifyPrimarySubtitleLoaded?: () => void;
|
||||
warn: (message: string) => void;
|
||||
log: (message: string) => void;
|
||||
getYoutubeOutputDir: () => string;
|
||||
createSubtitleTempDir?: () => Promise<string>;
|
||||
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
|
||||
};
|
||||
|
||||
type YoutubeFlowSession = {
|
||||
@@ -349,7 +352,9 @@ async function injectDownloadedSubtitles(
|
||||
}
|
||||
|
||||
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
|
||||
let primaryTrackId: number | null = primarySelection.existingTrackId;
|
||||
let primaryTrackId: number | null = primarySelection.injectedPath
|
||||
? null
|
||||
: primarySelection.existingTrackId;
|
||||
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
||||
@@ -423,6 +428,53 @@ async function injectDownloadedSubtitles(
|
||||
|
||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let activeSession: YoutubeFlowSession | null = null;
|
||||
const activeSubtitleTempDirs = new Set<string>();
|
||||
|
||||
const cleanupSubtitleTempDirs = (): void => {
|
||||
const dirs = [...activeSubtitleTempDirs];
|
||||
if (dirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!deps.cleanupSubtitleTempDirs) {
|
||||
activeSubtitleTempDirs.clear();
|
||||
return;
|
||||
}
|
||||
deps.cleanupSubtitleTempDirs(dirs);
|
||||
for (const dir of dirs) {
|
||||
activeSubtitleTempDirs.delete(dir);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupSubtitleTempDirsForNextLoad = (): void => {
|
||||
try {
|
||||
cleanupSubtitleTempDirs();
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to cleanup YouTube subtitle temp files: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareSubtitleOutputDir = async (fallbackOutputDir: string): Promise<string> => {
|
||||
if (!deps.createSubtitleTempDir || !deps.cleanupSubtitleTempDirs) {
|
||||
return fallbackOutputDir;
|
||||
}
|
||||
cleanupSubtitleTempDirsForNextLoad();
|
||||
try {
|
||||
const tempDir = await deps.createSubtitleTempDir();
|
||||
activeSubtitleTempDirs.add(tempDir);
|
||||
return tempDir;
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to create YouTube subtitle temp dir; using configured output dir: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return fallbackOutputDir;
|
||||
}
|
||||
};
|
||||
|
||||
const acquireSelectedTracks = async (input: {
|
||||
targetUrl: string;
|
||||
@@ -567,6 +619,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
osdProgress.setMessage('Downloading subtitles...');
|
||||
}
|
||||
try {
|
||||
const outputDir = await prepareSubtitleOutputDir(input.outputDir);
|
||||
let initialTrackListRaw: unknown = null;
|
||||
let existingPrimaryTrackId: number | null = null;
|
||||
let existingSecondaryTrackId: number | null = null;
|
||||
@@ -602,19 +655,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let primaryInjectedPath: string | null = null;
|
||||
let secondaryInjectedPath: string | null = null;
|
||||
|
||||
if (existingPrimaryTrackId !== null) {
|
||||
primarySidebarPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
primaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
@@ -622,7 +667,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
} else {
|
||||
const acquired = await acquireSelectedTracks({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
primaryTrack: input.primaryTrack,
|
||||
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||
@@ -641,7 +686,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
secondaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
track: input.secondaryTrack,
|
||||
})
|
||||
).path;
|
||||
@@ -685,8 +730,9 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
if (!refreshedActiveSubtitle) {
|
||||
return false;
|
||||
}
|
||||
deps.notifyPrimarySubtitleLoaded?.();
|
||||
try {
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath, input.url);
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||
@@ -877,5 +923,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
resolveActivePicker,
|
||||
cancelActivePicker,
|
||||
hasActiveSession: () => Boolean(activeSession),
|
||||
cleanupSubtitleTempDirs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './youtube-playback';
|
||||
|
||||
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
||||
@@ -22,3 +27,49 @@ test('isYoutubePlaybackActive checks both current media and mpv video paths', ()
|
||||
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
||||
});
|
||||
|
||||
test('isSameYoutubeMediaPath matches equivalent youtube urls by video id', () => {
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123&t=30', 'https://youtu.be/abc123'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath(
|
||||
'https://www.youtube.com/embed/abc123',
|
||||
'https://www.youtube-nocookie.com/embed/abc123',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123', 'https://youtu.be/xyz789'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isSameYoutubeMediaPath('/tmp/video.mkv', 'https://youtu.be/abc123'), false);
|
||||
});
|
||||
|
||||
test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube video', () => {
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123&t=30',
|
||||
cachedMediaPath: 'https://youtu.be/abc123',
|
||||
cachedCueCount: 12,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
cachedMediaPath: 'https://youtu.be/abc123',
|
||||
cachedCueCount: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
cachedMediaPath: 'https://youtu.be/other',
|
||||
cachedCueCount: 12,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,39 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (matchesYoutubeHost(host, 'youtu.be')) {
|
||||
return parsed.pathname.replace(/^\/+/, '').split('/')[0]?.trim() || null;
|
||||
}
|
||||
if (
|
||||
!matchesYoutubeHost(host, 'youtube.com') &&
|
||||
!matchesYoutubeHost(host, 'youtube-nocookie.com')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.pathname === '/watch') {
|
||||
return parsed.searchParams.get('v')?.trim() || null;
|
||||
}
|
||||
const pathSegments = parsed.pathname.replace(/^\/+/, '').split('/');
|
||||
if (pathSegments[0] === 'shorts' || pathSegments[0] === 'embed') {
|
||||
return pathSegments[1]?.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
@@ -31,6 +64,26 @@ export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolea
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameYoutubeMediaPath(
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
): boolean {
|
||||
const leftId = extractYoutubeVideoId(left);
|
||||
const rightId = extractYoutubeVideoId(right);
|
||||
return Boolean(leftId && rightId && leftId === rightId);
|
||||
}
|
||||
|
||||
export function shouldUseCachedYoutubeParsedCues(input: {
|
||||
videoPath: string | null | undefined;
|
||||
cachedMediaPath: string | null | undefined;
|
||||
cachedCueCount: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.cachedCueCount > 0 &&
|
||||
isSameYoutubeMediaPath(input.videoPath, input.cachedMediaPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function isYoutubePlaybackActive(
|
||||
currentMediaPath: string | null | undefined,
|
||||
currentVideoPath: string | null | undefined,
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
|
||||
function createTimerHarness() {
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
const timers = new Map<number, () => void | Promise<void>>();
|
||||
return {
|
||||
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
|
||||
schedule: (fn: () => void | Promise<void>): YoutubePrimarySubtitleNotificationTimer => {
|
||||
const id = nextId++;
|
||||
timers.set(id, fn);
|
||||
return { id };
|
||||
@@ -26,7 +26,14 @@ function createTimerHarness() {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
fn();
|
||||
void fn();
|
||||
}
|
||||
},
|
||||
runAllAsync: async () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
await fn();
|
||||
}
|
||||
},
|
||||
size: () => timers.size,
|
||||
@@ -195,3 +202,80 @@ test('notifier suppresses timer while app-owned youtube flow is still settling',
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifier suppresses stale delayed failure after primary subtitle load is confirmed', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
|
||||
]);
|
||||
runtime.markCurrentMediaPrimarySubtitleLoaded();
|
||||
|
||||
assert.equal(timers.size(), 0);
|
||||
timers.runAll();
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier suppresses delayed failure when live mpv state has downloaded primary selected', async () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
let liveStateReads = 0;
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
getCurrentSubtitleState: async () => {
|
||||
liveStateReads += 1;
|
||||
return {
|
||||
sid: 22,
|
||||
trackList: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
lang: 'en',
|
||||
title: 'English',
|
||||
external: true,
|
||||
selected: true,
|
||||
'main-selection': 1,
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 22,
|
||||
lang: 'ja',
|
||||
title: 'manual-ja.ja.srt',
|
||||
external: true,
|
||||
selected: true,
|
||||
'main-selection': 0,
|
||||
'external-filename': '/tmp/subminer-youtube-subtitles-aahLWu/manual-ja.ja.srt',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=uO2jfacqjYQ');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, selected: false },
|
||||
]);
|
||||
|
||||
assert.equal(timers.size(), 1);
|
||||
await timers.runAllAsync();
|
||||
|
||||
assert.equal(liveStateReads, 1);
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ type SubtitleTrackEntry = {
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
type CurrentSubtitleState = {
|
||||
sid: unknown;
|
||||
trackList: unknown[] | null;
|
||||
};
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
@@ -101,8 +106,12 @@ function hasSelectedPrimarySubtitle(
|
||||
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
notifyFailure: (message: string) => void;
|
||||
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
|
||||
schedule: (
|
||||
fn: () => void | Promise<void>,
|
||||
delayMs: number,
|
||||
) => YoutubePrimarySubtitleNotificationTimer;
|
||||
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
|
||||
getCurrentSubtitleState?: () => CurrentSubtitleState | null | Promise<CurrentSubtitleState | null>;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 5000;
|
||||
@@ -112,13 +121,35 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
|
||||
let lastReportedMediaPath: string | null = null;
|
||||
let appOwnedFlowInFlight = false;
|
||||
let primarySubtitleLoadedForCurrentMedia = false;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
deps.clearSchedule(pendingTimer);
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const maybeReportFailure = (): void => {
|
||||
const refreshCurrentSubtitleState = async (
|
||||
preferredLanguages: Set<string>,
|
||||
): Promise<boolean> => {
|
||||
const getCurrentSubtitleState = deps.getCurrentSubtitleState;
|
||||
if (!getCurrentSubtitleState) {
|
||||
return false;
|
||||
}
|
||||
let state: CurrentSubtitleState | null;
|
||||
try {
|
||||
state = await getCurrentSubtitleState();
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
currentSid = parseTrackId(state.sid);
|
||||
currentTrackList = Array.isArray(state.trackList) ? state.trackList : null;
|
||||
return hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages);
|
||||
};
|
||||
|
||||
const maybeReportFailure = async (): Promise<void> => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
@@ -126,13 +157,30 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
if (lastReportedMediaPath === mediaPath) {
|
||||
return;
|
||||
}
|
||||
if (appOwnedFlowInFlight) {
|
||||
return;
|
||||
}
|
||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||
if (preferredLanguages.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (primarySubtitleLoadedForCurrentMedia) {
|
||||
return;
|
||||
}
|
||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||
return;
|
||||
}
|
||||
if (deps.getCurrentSubtitleState && (await refreshCurrentSubtitleState(preferredLanguages))) {
|
||||
clearPendingTimer();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentMediaPath?.trim() !== mediaPath ||
|
||||
appOwnedFlowInFlight ||
|
||||
primarySubtitleLoadedForCurrentMedia
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastReportedMediaPath = mediaPath;
|
||||
deps.notifyFailure(
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
@@ -148,9 +196,12 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
if (primarySubtitleLoadedForCurrentMedia) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(async () => {
|
||||
pendingTimer = null;
|
||||
maybeReportFailure();
|
||||
await maybeReportFailure();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
@@ -160,6 +211,7 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||
if (currentMediaPath !== normalizedPath) {
|
||||
lastReportedMediaPath = null;
|
||||
primarySubtitleLoadedForCurrentMedia = false;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
currentSid = null;
|
||||
@@ -180,6 +232,14 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
clearPendingTimer();
|
||||
}
|
||||
},
|
||||
markCurrentMediaPrimarySubtitleLoaded: (): void => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
primarySubtitleLoadedForCurrentMedia = true;
|
||||
clearPendingTimer();
|
||||
},
|
||||
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
|
||||
appOwnedFlowInFlight = inFlight;
|
||||
if (inFlight) {
|
||||
|
||||
@@ -163,6 +163,7 @@ export interface AppState {
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
activeParsedSubtitleCues: SubtitleCue[];
|
||||
activeParsedSubtitleSource: string | null;
|
||||
activeParsedSubtitleMediaPath: string | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
subtitlePosition: SubtitlePosition | null;
|
||||
currentMediaPath: string | null;
|
||||
@@ -248,6 +249,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
currentSubtitleData: null,
|
||||
activeParsedSubtitleCues: [],
|
||||
activeParsedSubtitleSource: null,
|
||||
activeParsedSubtitleMediaPath: null,
|
||||
windowTracker: null,
|
||||
subtitlePosition: null,
|
||||
currentMediaPath: null,
|
||||
|
||||
+140
-1
@@ -1,7 +1,60 @@
|
||||
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 { buildAnimatedImageVideoFilter } from './media-generator';
|
||||
import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator';
|
||||
|
||||
async function withStubbedFfmpeg(
|
||||
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
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 argsPath = path.join(root, 'ffmpeg-args.txt');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const ffmpegPath = path.join(binDir, 'ffmpeg');
|
||||
fs.writeFileSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
|
||||
' echo " V..... libaom-av1"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
|
||||
'out=""',
|
||||
'for arg in "$@"; do out="$arg"; done',
|
||||
'printf avif > "$out"',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(ffmpegPath, 0o755);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
|
||||
const generator = new MediaGenerator(tempDir);
|
||||
|
||||
try {
|
||||
await run(generator, argsPath);
|
||||
} finally {
|
||||
generator.cleanup();
|
||||
process.env.PATH = originalPath;
|
||||
if (originalArgsPath === undefined) {
|
||||
delete process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
} else {
|
||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readFfmpegArgs(argsPath: string): string[] {
|
||||
return fs.readFileSync(argsPath, 'utf8').trim().split('\n');
|
||||
}
|
||||
|
||||
test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in is provided', () => {
|
||||
assert.equal(
|
||||
@@ -13,3 +66,89 @@ test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in i
|
||||
'tpad=start_duration=1.25:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
|
||||
test('generateAnimatedImage freezes first frame for leading audio padding', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2.5');
|
||||
assert.equal(
|
||||
args[args.indexOf('-vf') + 1],
|
||||
'tpad=start_duration=0.5:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage defaults to unpadded sentence timing', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 10, 12, undefined, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2');
|
||||
assert.equal(args[args.indexOf('-vf') + 1], 'fps=10,scale=w=640:h=-2');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage adds audio lead padding to existing word-audio lead-in', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
leadingStillDuration: 1.25,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2.5');
|
||||
assert.equal(
|
||||
args[args.indexOf('-vf') + 1],
|
||||
'tpad=start_duration=1.75:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage clips leading audio padding at the start of media', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 0.2, 1.2, 0.5, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '0.2');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '1.5');
|
||||
assert.equal(
|
||||
args[args.indexOf('-vf') + 1],
|
||||
'tpad=start_duration=0.2:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio defaults to unpadded sentence timing', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '0');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '1.7');
|
||||
});
|
||||
});
|
||||
|
||||
+11
-7
@@ -158,11 +158,12 @@ export class MediaGenerator {
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0.5,
|
||||
padding: number = 0,
|
||||
audioStreamIndex: number | null = null,
|
||||
): Promise<Buffer> {
|
||||
const start = Math.max(0, startTime - padding);
|
||||
const duration = endTime - startTime + 2 * padding;
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = endTime - start + safePadding;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
||||
@@ -310,7 +311,7 @@ export class MediaGenerator {
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0.5,
|
||||
padding: number = 0,
|
||||
options: {
|
||||
fps?: number;
|
||||
maxWidth?: number;
|
||||
@@ -319,9 +320,12 @@ export class MediaGenerator {
|
||||
leadingStillDuration?: number;
|
||||
} = {},
|
||||
): Promise<Buffer> {
|
||||
const start = Math.max(0, startTime - padding);
|
||||
const duration = endTime - startTime + 2 * padding;
|
||||
const { fps = 10, maxWidth = 640, maxHeight, crf = 35, leadingStillDuration = 0 } = options;
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime);
|
||||
const duration = endTime - startTime + safePadding;
|
||||
const effectiveLeadingPadding = Math.min(safePadding, start);
|
||||
const totalLeadingStillDuration = Math.max(0, leadingStillDuration) + effectiveLeadingPadding;
|
||||
|
||||
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||
|
||||
@@ -359,7 +363,7 @@ export class MediaGenerator {
|
||||
fps,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
leadingStillDuration,
|
||||
leadingStillDuration: totalLeadingStillDuration,
|
||||
}),
|
||||
...encoderArgs,
|
||||
'-y',
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
SessionNumericSelectionStartPayload,
|
||||
YoutubePickerOpenPayload,
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
@@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl
|
||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.youtubePickerCancel,
|
||||
);
|
||||
const onSessionNumericSelectionStartEvent =
|
||||
createQueuedIpcListenerWithPayload<SessionNumericSelectionStartPayload>(
|
||||
IPC_CHANNELS.event.sessionNumericSelectionStart,
|
||||
(payload) => payload as SessionNumericSelectionStartPayload,
|
||||
);
|
||||
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||
);
|
||||
@@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = {
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
|
||||
@@ -670,6 +670,21 @@ test('numeric selection ignores non-digit keys instead of falling through to oth
|
||||
}
|
||||
});
|
||||
|
||||
test('numeric selection start focuses overlay for follow-up digit keys', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.beginSessionNumericSelection('copySubtitleMultiple');
|
||||
|
||||
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
|
||||
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
function startPendingNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
timeoutMs: number = ctx.state.sessionActionTimeoutMs,
|
||||
): void {
|
||||
cancelPendingNumericSelection(false);
|
||||
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
||||
@@ -159,15 +160,17 @@ export function createKeyboardHandlers(
|
||||
timeout: setTimeout(() => {
|
||||
pendingNumericSelection = null;
|
||||
showSessionSelectionMessage(timeoutMessage);
|
||||
}, ctx.state.sessionActionTimeoutMs),
|
||||
}, timeoutMs),
|
||||
};
|
||||
showSessionSelectionMessage(promptMessage);
|
||||
}
|
||||
|
||||
function beginSessionNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
timeoutMs?: number,
|
||||
): void {
|
||||
startPendingNumericSelection(actionId);
|
||||
startPendingNumericSelection(actionId, timeoutMs);
|
||||
restoreOverlayKeyboardFocus();
|
||||
}
|
||||
|
||||
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
||||
|
||||
@@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void {
|
||||
}
|
||||
|
||||
function registerKeyboardCommandHandlers(): void {
|
||||
window.electronAPI.onSessionNumericSelectionStart((payload) => {
|
||||
runGuarded('session:numeric-selection-start', () => {
|
||||
keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs);
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -123,6 +123,7 @@ export const IPC_CHANNELS = {
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
|
||||
@@ -27,9 +27,17 @@ interface HistoryEntry {
|
||||
timingKey: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondaryText?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubtitleTimingBlock {
|
||||
displayText: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
export class SubtitleTimingTracker {
|
||||
private timings = new Map<string, TimingEntry>();
|
||||
private history: HistoryEntry[] = [];
|
||||
@@ -41,11 +49,12 @@ export class SubtitleTimingTracker {
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
recordSubtitle(text: string, startTime: number, endTime: number): void {
|
||||
recordSubtitle(text: string, startTime: number, endTime: number, secondaryText?: string): void {
|
||||
const normalizedText = this.normalizeText(text);
|
||||
if (!normalizedText) return;
|
||||
|
||||
const displayText = this.prepareDisplayText(text);
|
||||
const displaySecondaryText = secondaryText ? this.prepareDisplayText(secondaryText) : undefined;
|
||||
const timingKey = normalizedText;
|
||||
|
||||
this.timings.set(timingKey, {
|
||||
@@ -60,6 +69,7 @@ export class SubtitleTimingTracker {
|
||||
// Update timing to most recent occurrence
|
||||
lastEntry.startTime = startTime;
|
||||
lastEntry.endTime = endTime;
|
||||
lastEntry.secondaryText = displaySecondaryText;
|
||||
lastEntry.timestamp = Date.now();
|
||||
return;
|
||||
}
|
||||
@@ -69,6 +79,7 @@ export class SubtitleTimingTracker {
|
||||
timingKey,
|
||||
startTime,
|
||||
endTime,
|
||||
secondaryText: displaySecondaryText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
@@ -106,6 +117,23 @@ export class SubtitleTimingTracker {
|
||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks with their original event timings.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
*/
|
||||
getRecentEntries(count: number): SubtitleTimingBlock[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => ({
|
||||
displayText: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
secondaryText: entry.secondaryText,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for the most recent subtitle.
|
||||
*/
|
||||
|
||||
@@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult {
|
||||
staleMediaIds: number[];
|
||||
}
|
||||
|
||||
export interface SessionNumericSelectionStartPayload {
|
||||
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -451,6 +456,9 @@ export interface ElectronAPI {
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||
onSessionNumericSelectionStart: (
|
||||
callback: (payload: SessionNumericSelectionStartPayload) => void,
|
||||
) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||
|
||||
Reference in New Issue
Block a user