Compare commits

...

14 Commits

Author SHA1 Message Date
sudacode 05ac3a0382 test: address runtime nitpick coverage 2026-06-12 01:46:48 -07:00
sudacode 2c5a803839 fix(main): remove obsolete subtitle delay handler wiring 2026-06-12 01:25:26 -07:00
sudacode 572bdd1cf7 fix: address CodeRabbit review findings across runtime modules
- Extract filterLegacyMpvPluginFileCandidates, buildYomitanAnkiSettingsKey, setMpvCurrentSecondarySubText, runSupportAssetUpdatesForLauncherResult helpers
- Include forceOverride in yomitan anki settings cache key (was missing, causing incorrect cache hits)
- Detect same-PID stale stats daemon state to avoid self-connect
- Validate non-empty extension in buildFfmpegSubtitleExtractionArgs
- Drop unused message param from showOverlayLoadingStatusNotification
- Log and rethrow on session bindings artifact write failure
- Add unit tests for all extracted helpers
2026-06-12 01:22:20 -07:00
sudacode b9fe555b94 refactor(main): extract visible-overlay platform interaction runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 8f362063dd refactor(main): extract autoplay subtitle priming runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode eb1af727bb refactor(main): extract overlay geometry runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 1fc83a842d refactor(main): extract overlay notifications runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode a4edf53d21 refactor(main): extract stats server runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 1a3944aa4f refactor(main): extract password-store args, mpv plugin detection, yomitan anki sync, session bindings, log export from main.ts 2026-06-12 01:22:20 -07:00
sudacode 2d1b6cb78e refactor(main): extract internal subtitle extraction from main.ts 2026-06-12 01:22:20 -07:00
sudacode 0ef95cde09 refactor(main): extract update service runtime from main.ts 2026-06-12 01:18:40 -07:00
sudacode 94a65416ae fix(stats): strip Season N suffix from AniList title searches (#121) 2026-06-12 01:07:11 -07:00
sudacode 0a384a22c9 Replace subtitle delay actions with native mpv keybindings (#120) 2026-06-12 00:03:06 -07:00
sudacode b3b45521b6 fix(release): preserve attribution placement; default update notifs to o
- Move What's Changed/New Contributors before Installation in release notes
- Preserve committed attribution when regenerating via writeReleaseNotesForVersion
- Change notificationType default from 'both' to 'overlay' for new installs
2026-06-10 23:53:31 -07:00
79 changed files with 3956 additions and 2811 deletions
@@ -0,0 +1,4 @@
type: changed
area: overlay
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
@@ -0,0 +1,4 @@
type: fixed
area: release
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
@@ -0,0 +1,4 @@
type: changed
area: updates
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
+31 -5
View File
@@ -172,7 +172,7 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
@@ -290,15 +290,41 @@
] // Command setting. ] // Command setting.
}, },
{ {
"key": "Shift+BracketRight", // Key setting. "key": "Ctrl+Shift+ArrowLeft", // Key setting.
"command": [ "command": [
"__sub-delay-next-line" "sub-step",
-1
] // Command setting. ] // Command setting.
}, },
{ {
"key": "Shift+BracketLeft", // Key setting. "key": "Ctrl+Shift+ArrowRight", // Key setting.
"command": [ "command": [
"__sub-delay-prev-line" "sub-step",
1
] // Command setting.
},
{
"key": "KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
-0.1
] // Command setting.
},
{
"key": "Shift+KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
"key": "KeyX", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting. ] // Command setting.
}, },
{ {
+1 -1
View File
@@ -24,7 +24,7 @@
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes. - **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads. - **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering. - **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications. - **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
**Fixed** **Fixed**
+12 -9
View File
@@ -197,7 +197,7 @@ Configure automatic update checks and update notifications:
"updates": { "updates": {
"enabled": true, "enabled": true,
"checkIntervalHours": 24, "checkIntervalHours": 24,
"notificationType": "both", "notificationType": "overlay",
"channel": "stable" "channel": "stable"
} }
} }
@@ -207,7 +207,7 @@ Configure automatic update checks and update notifications:
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. | | `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. | | `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"both"`, which means overlay + system. | | `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow. When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
@@ -572,7 +572,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:** **Default keybindings:**
| Key | Command | Description | | Key | Command | Description |
| -------------------- | ----------------------------- | --------------------------------------- | | ----------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen | | `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
@@ -585,8 +585,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | | `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | | `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | | `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue | | `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue | | `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | | `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | | `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv | | `KeyQ` | `["quit"]` | Quit mpv |
@@ -616,11 +619,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null } { "key": "Space", "command": null }
``` ```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value. **Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs. Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
**See `config.example.jsonc`** for more keybinding examples and configuration options. **See `config.example.jsonc`** for more keybinding examples and configuration options.
@@ -656,7 +659,7 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
@@ -975,7 +978,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
+31 -5
View File
@@ -172,7 +172,7 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
@@ -290,15 +290,41 @@
] // Command setting. ] // Command setting.
}, },
{ {
"key": "Shift+BracketRight", // Key setting. "key": "Ctrl+Shift+ArrowLeft", // Key setting.
"command": [ "command": [
"__sub-delay-next-line" "sub-step",
-1
] // Command setting. ] // Command setting.
}, },
{ {
"key": "Shift+BracketLeft", // Key setting. "key": "Ctrl+Shift+ArrowRight", // Key setting.
"command": [ "command": [
"__sub-delay-prev-line" "sub-step",
1
] // Command setting.
},
{
"key": "KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
-0.1
] // Command setting.
},
{
"key": "Shift+KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
"key": "KeyX", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting. ] // Command setting.
}, },
{ {
+9 -6
View File
@@ -44,7 +44,7 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
These control playback and subtitle display. They require overlay window focus. These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action | | Shortcut | Action |
| -------------------- | --------------------------------------------------- | | -------------------- | ---------------------------------------------------------- |
| `Space` | Toggle mpv pause | | `Space` | Toggle mpv pause |
| `F` | Toggle fullscreen | | `F` | Toggle fullscreen |
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) | | `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
@@ -57,8 +57,11 @@ These control playback and subtitle display. They require overlay window focus.
| `ArrowDown` | Seek backward 60 seconds | | `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle | | `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle | | `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue | | `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue | | `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue |
| `z` | Shift subtitles 100 ms earlier |
| `Shift+Z` | Delay subtitles by 100 ms |
| `x` | Delay subtitles by 100 ms |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | | `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | | `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv | | `Q` | Quit mpv |
@@ -67,7 +70,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions. On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
@@ -76,7 +79,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts ## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- | | ------------------ | -------------------------------------------------------- | ------------------------------------------ |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` | | `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
@@ -109,7 +112,7 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second. When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
| Chord | Action | | Chord | Action |
| ----- | -------------------------------------- | | ----- | ---------------------------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) | | `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
+1 -1
View File
@@ -77,7 +77,7 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## Whats Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free. - `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
@@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
#### Subtitle File Parsing #### Subtitle File Parsing
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text. A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:** **Parsed cue structure:**
```typescript ```typescript
+18 -4
View File
@@ -270,10 +270,6 @@ function M.create(ctx)
return { "--replay-current-subtitle" } return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then elseif action_id == "playNextSubtitle" then
return { "--play-next-subtitle" } return { "--play-next-subtitle" }
elseif action_id == "shiftSubDelayPrevLine" then
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
elseif action_id == "cycleRuntimeOption" then elseif action_id == "cycleRuntimeOption" then
local runtime_option_id = payload and payload.runtimeOptionId or nil local runtime_option_id = payload and payload.runtimeOptionId or nil
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
@@ -350,6 +346,16 @@ function M.create(ctx)
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs) invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
end end
local function is_supported_binding(binding)
if binding.actionType == "mpv-command" then
return type(binding.command) == "table" and binding.command[1] ~= nil
end
if binding.actionType == "session-action" then
return build_cli_args(binding.actionId, binding.payload, binding.cliArgs) ~= nil
end
return false
end
local function load_artifact() local function load_artifact()
local artifact_path = environment.resolve_session_bindings_artifact_path() local artifact_path = environment.resolve_session_bindings_artifact_path()
local raw = read_file(artifact_path) local raw = read_file(artifact_path)
@@ -385,6 +391,13 @@ function M.create(ctx)
local generation = state.session_binding_generation local generation = state.session_binding_generation
for index, binding in ipairs(artifact.bindings) do for index, binding in ipairs(artifact.bindings) do
if not is_supported_binding(binding) then
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported session binding from artifact"
)
else
local key_names = key_spec_to_mpv_bindings(binding.key) local key_names = key_spec_to_mpv_bindings(binding.key)
if key_names then if key_names then
for key_index, key_name in ipairs(key_names) do for key_index, key_name in ipairs(key_names) do
@@ -407,6 +420,7 @@ function M.create(ctx)
) )
end end
end end
end
remove_binding_names(previous_binding_names) remove_binding_names(previous_binding_names)
state.session_binding_names = next_binding_names state.session_binding_names = next_binding_names
-80
View File
@@ -1,80 +0,0 @@
## Highlights
### Breaking Changes
- **Notification Type `both`**: This setting now routes to overlay + system notifications instead of mpv OSD + system.
- Set `notificationType` to `osd-system` in `config.jsonc` to keep the previous OSD + system behavior.
- `osd` and `osd-system` remain valid config-file values but no longer appear as options in the Settings UI.
### Added
- **Overlay Notifications**: A new in-app notification stack replaces bare OSD text for most alerts, using Catppuccin Macchiato styling with 3-second auto-dismiss.
- Position via `notifications.overlayPosition` (top-left, top-center, or top-right; default top-right). Startup, mining, sync, and error alerts queue for the overlay instead of falling back to raw OSD.
- Mined-card notifications include card thumbnails and an **Open in Anki** button; update-available notifications include a one-click **Update** button.
- **Notification History Panel**: A slide-in panel logging every notification from the current session, toggled with `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`).
- Works whether the overlay or mpv has focus; slides in from the same edge as the notification stack.
- Entries retain thumbnails and action buttons (Open in Anki, etc.) and can be removed individually or cleared all at once.
- **Stats Search**: A new Search tab for real-time subtitle sentence search across your library.
- Matches by headword with media context; mine directly to sentence cards, word cards, or audio cards.
- Sentence cards are queued before slow media generation finishes, so the card lands in Anki quickly with audio filled in later.
### Changed
- **AniSkip**: Intro detection now runs in the SubMiner app rather than the mpv plugin.
- Covers all files in the mpv session including playlist advances; the plugin no longer makes any network calls.
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` hot-reload without restarting playback. Requires SubMiner to be connected to mpv — plugin-only sessions no longer fetch skip windows.
- **Library**: Local and Jellyfin entries are now split by season using folder structure first, filename parsing as fallback.
- Existing combined-series stats rows are automatically migrated to season-specific entries on startup.
- Anime detail and cover art refresh immediately after manually changing an AniList entry.
- **Stats — Vocabulary Review**: Hide Known/Hide Kana filters are remembered across sessions; Related Seen Words now matches on shared readings or kanji; duplicate-collapsed exclusions cover all token variants.
- **Stats — Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
- **Stats — Library Browsing**: Remembers card size between sessions; retries stored cover art preserving PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; session deletes show progress and refresh faster.
- **Stats Mining**: Several reliability improvements when mining from Search and vocabulary examples.
- Empty `ankiConnect.deck` falls back to Yomitan's configured mining deck; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- Invalid stored timings and out-of-order subtitle pairs are skipped before FFmpeg runs; partial media failures are shown inline rather than silently dropped.
### Fixed
- **AniList**: Entries are now marked completed when a post-watch sync reaches the final known episode of the season.
- **AniSkip**: Fixed intro markers disappearing after same-media mpv reloads; fixed detection for intros starting at 0 seconds and common release-group filenames.
- **Jellyfin**: Session restarts after setup login so the websocket reconnects with fresh credentials; session stops on logout.
- **Anki — Sentence Cards**: Generated audio is written only to the configured sentence audio field and no longer also fills the expression audio field.
- **Stats Mining**: Word audio uses configured Yomitan sources; English subtitle text is no longer written to word cards; sentence clips correctly update the SentenceAudio field.
- **Overlay Startup**: Subtitle bars are hoverable and clickable as soon as the first subtitle line appears; Linux overlay input is primed from the first measured surface so first-line subtitles and startup notifications are immediately clickable; an OSD spinner now shows from mpv connect through to content-ready.
- **Startup Autoplay**: SubMiner now releases playback after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
<details>
<summary>Internal changes</summary>
### Internal
- Release notes now credit contributors with a What's Changed list and a New Contributors section, resolved from changelog fragments via git and the GitHub API.
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes and direct contributors to consolidate same-PR churn before merging.
</details>
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
## Whats Changed
- feat(notifications): add overlay notifications with position config by @ksyasuda in #110
- feat(stats): speed up session maintenance and improve stats UI by @ksyasuda in #111
- [codex] Restart Jellyfin remote session after setup login by @bee-san in #112
- docs(changelog): require reconciled fragments, not just new ones by @ksyasuda in #113
- feat(release): add contributor attribution to release notes by @ksyasuda in #114
- fix(anilist): mark entry completed when final episode is reached by @ksyasuda in #115
- feat(aniskip): move intro detection from mpv plugin to app runtime by @ksyasuda in #117
- fix(anki): write sentence card audio only to sentence audio field by @ksyasuda in #118
+94 -2
View File
@@ -1122,13 +1122,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
path.join(projectRoot, 'release', 'release-notes.md'), path.join(projectRoot, 'release', 'release-notes.md'),
'utf8', 'utf8',
); );
assert.match(releaseNotes, /## Whats Changed\n\n/); assert.match(releaseNotes, /## What's Changed\n\n/);
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/); assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/); assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
assert.match( assert.match(
releaseNotes, releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/, /## New Contributors\n\n- @bee-san made their first contribution in #112/,
); );
assert.ok(
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
"What's Changed should follow Highlights",
);
assert.ok(
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
'contributor attribution should appear before Installation',
);
assert.doesNotMatch(releaseNotes, /## Whats Changed/);
assert.doesNotMatch( assert.doesNotMatch(
releaseNotes, releaseNotes,
/ksyasuda made their first contribution/, /ksyasuda made their first contribution/,
@@ -1137,13 +1146,96 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
// Attribution is a release-notes concern only; the CHANGELOG stays clean. // Attribution is a release-notes concern only; the CHANGELOG stays clean.
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.doesNotMatch(changelog, /Whats Changed/); assert.doesNotMatch(changelog, /What's Changed|Whats Changed/);
assert.doesNotMatch(changelog, /New Contributors/); assert.doesNotMatch(changelog, /New Contributors/);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
}); });
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
const { writeReleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('release-notes-preserve-attribution');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
'## v0.8.0 (2026-04-17)',
'### Added',
'- Polished: released feature.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'### Internal',
'- Polished: internal release note.',
'',
'</details>',
'',
].join('\n');
const committedReleaseNotes = [
'## Highlights',
'### Added',
'- Old generated body.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'',
'## Whats Changed',
'',
'- feat(release): add contributor attribution by @ksyasuda in #114',
'',
'## New Contributors',
'',
'- @bee-san made their first contribution in #112',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
committedReleaseNotes,
'utf8',
);
try {
const outputPath = writeReleaseNotesForVersion({
cwd: projectRoot,
version: '0.8.0',
});
const releaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
assert.match(
releaseNotes,
/## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
);
assert.match(
releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
);
assert.ok(
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
"What's Changed should follow Highlights",
);
assert.ok(
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
'New Contributors should appear before Installation',
);
assert.doesNotMatch(releaseNotes, /## Whats Changed/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => { test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule(); const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section'); const workspace = createWorkspace('reuse-existing-section');
+48 -3
View File
@@ -433,12 +433,45 @@ function resolveContributionsForFragments(
); );
} }
function isWhatsChangedHeading(line: string): boolean {
return line === "## What's Changed" || line === '## Whats Changed';
}
function extractContributorSections(releaseNotes: string): string[] {
const lines = releaseNotes.split(/\r?\n/);
const start = lines.findIndex(isWhatsChangedHeading);
if (start === -1) {
return [];
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (line.startsWith('## ') && !isWhatsChangedHeading(line) && line !== '## New Contributors') {
end = index;
break;
}
}
const block = lines.slice(start, end);
while (block.length > 0 && block[block.length - 1] === '') {
block.pop();
}
if (block.length === 0) {
return [];
}
block[0] = "## What's Changed";
block.push('');
return block;
}
function renderContributorsSections(contributions: Contribution[]): string[] { function renderContributorsSections(contributions: Contribution[]): string[] {
if (contributions.length === 0) { if (contributions.length === 0) {
return []; return [];
} }
const lines: string[] = ['## Whats Changed', '']; const lines: string[] = ["## What's Changed", ''];
for (const contribution of contributions) { for (const contribution of contributions) {
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`); lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
} }
@@ -635,14 +668,18 @@ function renderReleaseNotes(
options?: { options?: {
disclaimer?: string; disclaimer?: string;
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[];
}, },
): string { ): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
const contributorSections =
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
return [ return [
...prefix, ...prefix,
'## Highlights', '## Highlights',
changes, changes,
'', '',
...contributorSections,
'## Installation', '## Installation',
'', '',
'See the README and docs/installation guide for full setup steps.', 'See the README and docs/installation guide for full setup steps.',
@@ -656,7 +693,6 @@ function renderReleaseNotes(
'', '',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'', '',
...renderContributorsSections(options?.contributions ?? []),
].join('\n'); ].join('\n');
} }
@@ -668,6 +704,7 @@ function writeReleaseNotesFile(
disclaimer?: string; disclaimer?: string;
outputPath?: string; outputPath?: string;
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[];
}, },
): string { ): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -960,6 +997,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string { export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {}); const version = resolveVersion(options ?? {});
const changelogPath = path.join(cwd, 'CHANGELOG.md'); const changelogPath = path.join(cwd, 'CHANGELOG.md');
@@ -970,7 +1008,14 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
throw new Error(`Missing CHANGELOG section for v${version}.`); throw new Error(`Missing CHANGELOG section for v${version}.`);
} }
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps); const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
const contributorSections = existsSync(releaseNotesPath)
? extractContributorSections(readFileSync(releaseNotesPath, 'utf8'))
: [];
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps, {
contributorSections,
});
} }
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string { export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
+48 -4
View File
@@ -165,6 +165,46 @@ local ctx = {
actionType = "mpv-command", actionType = "mpv-command",
command = { "sub-seek", 1 }, command = { "sub-seek", 1 },
}, },
{
key = {
code = "ArrowLeft",
modifiers = { "ctrl", "shift" },
},
actionType = "mpv-command",
command = { "sub-step", -1 },
},
{
key = {
code = "ArrowRight",
modifiers = { "ctrl", "shift" },
},
actionType = "mpv-command",
command = { "sub-step", 1 },
},
{
key = {
code = "KeyZ",
modifiers = {},
},
actionType = "mpv-command",
command = { "add", "sub-delay", -0.1 },
},
{
key = {
code = "KeyZ",
modifiers = { "shift" },
},
actionType = "mpv-command",
command = { "add", "sub-delay", 0.1 },
},
{
key = {
code = "KeyX",
modifiers = {},
},
actionType = "mpv-command",
command = { "add", "sub-delay", 0.1 },
},
{ {
key = { key = {
code = "BracketRight", code = "BracketRight",
@@ -323,6 +363,11 @@ local expected_mpv_bindings = {
{ keys = "DOWN", command = { "seek", -60 } }, { keys = "DOWN", command = { "seek", -60 } },
{ keys = "H", command = { "sub-seek", -1 } }, { keys = "H", command = { "sub-seek", -1 } },
{ keys = "L", command = { "sub-seek", 1 } }, { keys = "L", command = { "sub-seek", 1 } },
{ keys = "Ctrl+Shift+LEFT", command = { "sub-step", -1 } },
{ keys = "Ctrl+Shift+RIGHT", command = { "sub-step", 1 } },
{ keys = "z", command = { "add", "sub-delay", -0.1 } },
{ keys = "Z", command = { "add", "sub-delay", 0.1 } },
{ keys = "x", command = { "add", "sub-delay", 0.1 } },
{ keys = "q", command = { "quit" } }, { keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } }, { keys = "Ctrl+w", command = { "quit" } },
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } }, { keys = "MBTN_BACK", command = { "sub-seek", -1 } },
@@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do
end end
local expected_cli_bindings = { local expected_cli_bindings = {
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
{ keys = "}", flag = "--shift-sub-delay-next-line" },
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" }, { keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" }, { keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" }, { keys = "Ctrl+H", flag = "--replay-current-subtitle" },
@@ -365,6 +406,9 @@ for _, expected in ipairs(expected_cli_bindings) do
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag) assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
end end
assert_true(find_binding("Shift+]") == nil, "retired subtitle delay action should not register Shift+]")
assert_true(find_binding("Shift+[") == nil, "retired subtitle delay action should not register Shift+[")
local play_next = find_binding("Ctrl+L") local play_next = find_binding("Ctrl+L")
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form") assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
+7 -4
View File
@@ -101,8 +101,6 @@ test('parseArgs captures session action forwarding flags', () => {
'--toggle-primary-subtitle-bar', '--toggle-primary-subtitle-bar',
'--replay-current-subtitle', '--replay-current-subtitle',
'--play-next-subtitle', '--play-next-subtitle',
'--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line',
'--cycle-runtime-option', '--cycle-runtime-option',
'anki.autoUpdateNewCards:prev', 'anki.autoUpdateNewCards:prev',
'--session-action', '--session-action',
@@ -120,8 +118,6 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.togglePrimarySubtitleBar, true); assert.equal(args.togglePrimarySubtitleBar, true);
assert.equal(args.replayCurrentSubtitle, true); assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true); assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1); assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' }); assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
@@ -131,6 +127,13 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true); assert.equal(shouldStartApp(args), true);
}); });
test('parseArgs ignores retired subtitle delay shift flags', () => {
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
assert.equal(hasExplicitCommand(args), false);
assert.equal(shouldStartApp(args), false);
});
test('parseArgs captures internal playback feedback command', () => { test('parseArgs captures internal playback feedback command', () => {
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']); const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
-16
View File
@@ -41,8 +41,6 @@ export interface CliArgs {
openPlaylistBrowser: boolean; openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean; replayCurrentSubtitle: boolean;
playNextSubtitle: boolean; playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
playbackFeedback?: string; playbackFeedback?: string;
cycleRuntimeOptionId?: string; cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1; cycleRuntimeOptionDirection?: 1 | -1;
@@ -149,8 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined, playbackFeedback: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
@@ -296,8 +292,6 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true; else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true; else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--playback-feedback=')) { else if (arg.startsWith('--playback-feedback=')) {
const value = arg.slice('--playback-feedback='.length).trim(); const value = arg.slice('--playback-feedback='.length).trim();
if (value) args.playbackFeedback = value; if (value) args.playbackFeedback = value;
@@ -562,8 +556,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
@@ -638,8 +630,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
@@ -705,8 +695,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
@@ -766,8 +754,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
@@ -832,8 +818,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
+2 -2
View File
@@ -153,7 +153,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.stats.autoOpenBrowser, false); assert.equal(config.stats.autoOpenBrowser, false);
assert.equal(config.updates.enabled, true); assert.equal(config.updates.enabled, true);
assert.equal(config.updates.checkIntervalHours, 24); assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'both'); assert.equal(config.updates.notificationType, 'overlay');
assert.equal(config.updates.channel, 'stable'); assert.equal(config.updates.channel, 'stable');
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath); assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
assert.equal(config.mpv.backend, 'auto'); assert.equal(config.mpv.backend, 'auto');
@@ -2814,7 +2814,7 @@ test('template generator includes known keys', () => {
); );
assert.match( assert.match(
output, output,
/"notificationType": "both",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/, /"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
); );
assert.match( assert.match(
output, output,
+1 -1
View File
@@ -128,7 +128,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
updates: { updates: {
enabled: true, enabled: true,
checkIntervalHours: 24, checkIntervalHours: 24,
notificationType: 'both', notificationType: 'overlay',
channel: 'stable', channel: 'stable',
}, },
notifications: { notifications: {
@@ -234,3 +234,16 @@ test('default keybindings include replay and next subtitle controls', () => {
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']); assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']); assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
}); });
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
const keybindingMap = new Map(
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
);
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
});
+5 -7
View File
@@ -55,8 +55,6 @@ export const SPECIAL_COMMANDS = {
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
} as const; } as const;
@@ -72,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'ArrowDown', command: ['seek', -60] }, { key: 'ArrowDown', command: ['seek', -60] },
{ key: 'Shift+KeyH', command: ['sub-seek', -1] }, { key: 'Shift+KeyH', command: ['sub-seek', -1] },
{ key: 'Shift+KeyL', command: ['sub-seek', 1] }, { key: 'Shift+KeyL', command: ['sub-seek', 1] },
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] }, { key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
{ { key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
key: 'Shift+BracketLeft', { key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], { key: 'Shift+KeyZ', command: ['add', 'sub-delay', 0.1] },
}, { key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, { key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] }, { key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
-2
View File
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
-2
View File
@@ -49,8 +49,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false, togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined, playbackFeedback: undefined,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
-12
View File
@@ -537,18 +537,6 @@ export function handleCliCommand(
'playNextSubtitle', 'playNextSubtitle',
'Play next subtitle failed', 'Play next subtitle failed',
); );
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.playbackFeedback) { } else if (args.playbackFeedback) {
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd; const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
showFeedback(args.playbackFeedback); showFeedback(args.playbackFeedback);
-1
View File
@@ -10,7 +10,6 @@ export {
unregisterOverlayShortcutsRuntime, unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut'; } from './overlay-shortcut';
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler'; export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command'; export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
export { export {
copyCurrentSubtitle, copyCurrentSubtitle,
+15 -11
View File
@@ -15,8 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
}, },
@@ -48,9 +46,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
mpvPlayNextSubtitle: () => { mpvPlayNextSubtitle: () => {
calls.push('next'); calls.push('next');
}, },
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
mpvSendCommand: (command) => { mpvSendCommand: (command) => {
sentCommands.push(command); sentCommands.push(command);
}, },
@@ -111,20 +106,29 @@ test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle tra
]); ]);
}); });
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => { test('handleMpvCommandFromIpc emits mpv OSD for subtitle delay keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions(); const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options); handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]); assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
assert.deepEqual(osd, []); assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']); assert.deepEqual(playbackFeedback, []);
}); });
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => { test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['sub-step', 1], options);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['sub-step', 1]]);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, []);
});
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
const { options, calls, sentCommands, osd } = createOptions(); const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__sub-delay-next-line'], options); handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
assert.deepEqual(calls, ['shift:next']); assert.deepEqual(calls, []);
assert.deepEqual(sentCommands, []); assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
assert.deepEqual(osd, []); assert.deepEqual(osd, []);
}); });
+22 -27
View File
@@ -13,8 +13,6 @@ export interface HandleMpvCommandFromIpcOptions {
RUNTIME_OPTION_CYCLE_PREFIX: string; RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string; REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string; PLAY_NEXT_SUBTITLE: string;
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string; YOUTUBE_PICKER_OPEN: string;
PLAYLIST_BROWSER_OPEN: string; PLAYLIST_BROWSER_OPEN: string;
}; };
@@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions {
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
showPlaybackFeedback?: (text: string) => void; showPlaybackFeedback?: (text: string) => void;
mpvReplaySubtitle: () => void; mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void; mpvPlayNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
mpvSendCommand: (command: (string | number)[]) => void; mpvSendCommand: (command: (string | number)[]) => void;
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>; resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
@@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([
'multiply', 'multiply',
]); ]);
function resolveProxyCommandOsdTemplate(command: (string | number)[]): string | null { interface ProxyCommandFeedback {
template: string;
rawMpvOsd: boolean;
}
function resolveProxyCommandOsdTemplate(command: (string | number)[]): ProxyCommandFeedback | null {
const operation = typeof command[0] === 'string' ? command[0] : ''; const operation = typeof command[0] === 'string' ? command[0] : '';
if (operation === 'sub-step') {
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
}
const property = typeof command[1] === 'string' ? command[1] : ''; const property = typeof command[1] === 'string' ? command[1] : '';
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null; if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
if (property === 'sub-pos') { if (property === 'sub-pos') {
return 'Subtitle position: ${sub-pos}'; return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false };
} }
if (property === 'sid') { if (property === 'sid') {
return 'Subtitle track: ${sid}'; return { template: 'Subtitle track: ${sid}', rawMpvOsd: false };
} }
if (property === 'secondary-sid') { if (property === 'secondary-sid') {
return 'Secondary subtitle track: ${secondary-sid}'; return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false };
} }
if (property === 'sub-delay') { if (property === 'sub-delay') {
return 'Subtitle delay: ${sub-delay}'; return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
} }
return null; return null;
} }
@@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd(
command: (string | number)[], command: (string | number)[],
options: HandleMpvCommandFromIpcOptions, options: HandleMpvCommandFromIpcOptions,
): void { ): void {
const template = resolveProxyCommandOsdTemplate(command); const feedback = resolveProxyCommandOsdTemplate(command);
if (!template) return; if (!feedback) return;
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd; const showFeedback = feedback.rawMpvOsd
? (options.showRawMpvOsd ?? options.showMpvOsd)
: (options.showPlaybackFeedback ?? options.showMpvOsd);
const emit = async () => { const emit = async () => {
try { try {
const resolved = await options.resolveProxyCommandOsd?.(command); const resolved = await options.resolveProxyCommandOsd?.(command);
showFeedback(resolved || template); showFeedback(resolved || feedback.template);
} catch { } catch {
showFeedback(template); showFeedback(feedback.template);
} }
}; };
@@ -118,20 +127,6 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
) {
const direction =
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START
? 'next'
: 'previous';
options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => {
options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`);
});
return;
}
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (!options.hasRuntimeOptionsManager()) return; if (!options.hasRuntimeOptionsManager()) return;
const [, idToken, directionToken] = first.split(':'); const [, idToken, directionToken] = first.split(':');
+1
View File
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
playNextSubtitle?: () => void; playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void; setSubVisibility?: (visible: boolean) => void;
setSecondarySubVisibility?: (visible: boolean) => void; setSecondarySubVisibility?: (visible: boolean) => void;
setCurrentSecondarySubText?: (text: string) => void;
} }
export function showMpvOsdRuntime( export function showMpvOsdRuntime(
@@ -47,9 +47,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
}, },
replayCurrentSubtitle: () => calls.push('replay'), replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('play-next'), playNextSubtitle: () => calls.push('play-next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'), playNextPlaylistItem: () => calls.push('playlist-next'),
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
-7
View File
@@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps {
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>; openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
playNextSubtitle: () => void; playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void; playNextPlaylistItem: () => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
@@ -124,12 +123,6 @@ export async function dispatchSessionAction(
case 'playNextSubtitle': case 'playNextSubtitle':
deps.playNextSubtitle(); deps.playNextSubtitle();
return; return;
case 'shiftSubDelayPrevLine':
await deps.shiftSubDelayToAdjacentSubtitle('previous');
return;
case 'shiftSubDelayNextLine':
await deps.shiftSubDelayToAdjacentSubtitle('next');
return;
case 'cycleRuntimeOption': { case 'cycleRuntimeOption': {
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined; const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
if (!runtimeOptionId) { if (!runtimeOptionId) {
+23 -2
View File
@@ -287,8 +287,6 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => { test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
const expectedSpecialActions: Record<string, string> = { const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
@@ -320,6 +318,29 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
} }
}); });
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Shift+BracketLeft', ['__sub-delay-prev-line']),
createKeybinding('Shift+BracketRight', ['__sub-delay-next-line']),
],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(
result.bindings.map((binding) => ({
actionType: binding.actionType,
command: binding.actionType === 'mpv-command' ? binding.command : undefined,
})),
[
{ actionType: 'mpv-command', command: ['__sub-delay-prev-line'] },
{ actionType: 'mpv-command', command: ['__sub-delay-next-line'] },
],
);
});
test('compileSessionBindings omits disabled bindings', () => { test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({ const result = compileSessionBindings({
shortcuts: createShortcuts({ shortcuts: createShortcuts({
-8
View File
@@ -319,14 +319,6 @@ function resolveCommandBinding(
if (command.length !== 1) return null; if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' }; return { actionType: 'session-action', actionId: 'playNextSubtitle' };
} }
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) { if (command.length !== 1) {
return null; return null;
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
@@ -1,156 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
function createMpvClient(props: Record<string, unknown>) {
return {
connected: true,
requestProperty: async (name: string) => props[name],
};
}
test('shift subtitle delay to next cue using active external srt track', async () => {
const commands: Array<Array<string | number>> = [];
const osd: string[] = [];
let loadCount = 0;
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 2,
external: true,
'external-filename': '/tmp/subs.srt',
},
],
sid: 2,
'sub-start': 3.0,
}),
loadSubtitleSourceText: async () => {
loadCount += 1;
return `1
00:00:01,000 --> 00:00:02,000
line-1
2
00:00:03,000 --> 00:00:04,000
line-2
3
00:00:05,000 --> 00:00:06,000
line-3`;
},
sendMpvCommand: (command) => commands.push(command),
showMpvOsd: (text) => osd.push(text),
});
await handler('next');
await handler('next');
assert.equal(loadCount, 1);
assert.equal(commands.length, 2);
const delta = commands[0]?.[2];
assert.equal(commands[0]?.[0], 'add');
assert.equal(commands[0]?.[1], 'sub-delay');
assert.equal(typeof delta, 'number');
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
});
test('shift subtitle delay to previous cue using active external ass track', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 4,
external: true,
'external-filename': '/tmp/subs.ass',
},
],
sid: 4,
'sub-start': 2.0,
}),
loadSubtitleSourceText: async () => `[Events]
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
sendMpvCommand: (command) => commands.push(command),
showMpvOsd: () => {},
});
await handler('previous');
const delta = commands[0]?.[2];
assert.equal(typeof delta, 'number');
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
});
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
const shiftedDelays: number[] = [];
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 2,
external: true,
'external-filename': '/tmp/subs.srt',
},
],
sid: 2,
'sub-start': 3.0,
'sub-delay': 0.5,
}),
loadSubtitleSourceText: async () => `1
00:00:03,000 --> 00:00:04,000
line-1
2
00:00:05,000 --> 00:00:06,000
line-2`,
sendMpvCommand: () => {},
showMpvOsd: () => {},
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
});
await handler('next');
assert.deepEqual(shiftedDelays, [2.5]);
});
test('shift subtitle delay throws when no next cue exists', async () => {
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 1,
external: true,
'external-filename': '/tmp/subs.vtt',
},
],
sid: 1,
'sub-start': 5.0,
}),
loadSubtitleSourceText: async () => `WEBVTT
00:00:01.000 --> 00:00:02.000
line-1
00:00:03.000 --> 00:00:04.000
line-2
00:00:05.000 --> 00:00:06.000
line-3`,
sendMpvCommand: () => {},
showMpvOsd: () => {},
});
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
});
-210
View File
@@ -1,210 +0,0 @@
type SubtitleDelayShiftDirection = 'next' | 'previous';
type MpvClientLike = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
external?: unknown;
'external-filename'?: unknown;
};
type SubtitleCueCacheEntry = {
starts: number[];
};
type SubtitleDelayShiftDeps = {
getMpvClient: () => MpvClientLike | null;
loadSubtitleSourceText: (source: string) => Promise<string>;
sendMpvCommand: (command: Array<string | number>) => void;
showMpvOsd: (text: string) => void;
onSubtitleDelayShifted?: (delaySeconds: number) => void;
};
function asTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value.trim());
if (Number.isInteger(parsed)) return parsed;
}
return null;
}
function parseSrtOrVttStartTimes(content: string): number[] {
const starts: number[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
);
if (!match) continue;
const hours = Number(match[1] || 0);
const minutes = Number(match[2] || 0);
const seconds = Number(match[3] || 0);
const millis = Number(String(match[4]).padEnd(3, '0'));
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
}
return starts;
}
function parseAssStartTimes(content: string): number[] {
const starts: number[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
);
if (!match) continue;
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
if (secondsRaw === undefined) continue;
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
const hours = Number(hoursRaw);
const minutes = Number(minutesRaw);
const wholeSeconds = Number(wholeSecondsRaw);
const fraction = Number(`0.${fractionRaw}`);
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
}
return starts;
}
function normalizeCueStarts(starts: number[]): number[] {
const sorted = starts
.filter((value) => Number.isFinite(value) && value >= 0)
.sort((a, b) => a - b);
if (sorted.length === 0) return [];
const deduped: number[] = [sorted[0]!];
for (let i = 1; i < sorted.length; i += 1) {
const current = sorted[i]!;
const previous = deduped[deduped.length - 1]!;
if (Math.abs(current - previous) > 0.0005) {
deduped.push(current);
}
}
return deduped;
}
function parseCueStarts(content: string, source: string): number[] {
const normalizedSource = source.toLowerCase().split('?')[0] || '';
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
const parseAssLike = () => parseAssStartTimes(content);
let starts: number[] = [];
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
starts = parseAssLike();
if (starts.length === 0) {
starts = parseSrtLike();
}
} else {
starts = parseSrtLike();
if (starts.length === 0) {
starts = parseAssLike();
}
}
const normalized = normalizeCueStarts(starts);
if (normalized.length === 0) {
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
}
return normalized;
}
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
const sid = asTrackId(sidRaw);
if (sid === null) {
throw new Error('No active subtitle track selected.');
}
if (!Array.isArray(trackListRaw)) {
throw new Error('Could not inspect subtitle track list.');
}
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
if (!entry || typeof entry !== 'object') return false;
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && asTrackId(track.id) === sid;
});
if (!activeTrack) {
throw new Error('No active subtitle track found in mpv track list.');
}
if (activeTrack.external !== true) {
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
}
const source =
typeof activeTrack['external-filename'] === 'string'
? activeTrack['external-filename'].trim()
: '';
if (!source) {
throw new Error('Active subtitle track has no external subtitle source path.');
}
return source;
}
function findAdjacentCueStart(
starts: number[],
currentStart: number,
direction: SubtitleDelayShiftDirection,
): number {
const epsilon = 0.0005;
if (direction === 'next') {
const target = starts.find((value) => value > currentStart + epsilon);
if (target === undefined) {
throw new Error('No next subtitle cue found for active subtitle source.');
}
return target;
}
for (let index = starts.length - 1; index >= 0; index -= 1) {
const value = starts[index]!;
if (value < currentStart - epsilon) {
return value;
}
}
throw new Error('No previous subtitle cue found for active subtitle source.');
}
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
const cueCache = new Map<string, SubtitleCueCacheEntry>();
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
const client = deps.getMpvClient();
if (!client || !client.connected) {
throw new Error('MPV not connected.');
}
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('sub-start'),
client.requestProperty('sub-delay'),
]);
const currentStart =
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
if (currentStart === null) {
throw new Error('Current subtitle start time is unavailable.');
}
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
let cueStarts = cueCache.get(source)?.starts;
if (!cueStarts) {
const content = await deps.loadSubtitleSourceText(source);
cueStarts = parseCueStarts(content, source);
cueCache.set(source, { starts: cueStarts });
}
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
const delta = targetStart - currentStart;
deps.sendMpvCommand(['add', 'sub-delay', delta]);
const currentDelay =
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
try {
deps.onSubtitleDelayShifted?.(currentDelay + delta);
} catch {}
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
};
}
+310 -1971
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -226,10 +226,10 @@ export interface MpvCommandRuntimeServiceDepsParams {
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback']; showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand']; mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd']; resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected']; isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
@@ -424,10 +424,10 @@ export function createMpvCommandRuntimeServiceDeps(
openPlaylistBrowser: params.openPlaylistBrowser, openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd, showMpvOsd: params.showMpvOsd,
showRawMpvOsd: params.showRawMpvOsd,
showPlaybackFeedback: params.showPlaybackFeedback, showPlaybackFeedback: params.showPlaybackFeedback,
mpvReplaySubtitle: params.mpvReplaySubtitle, mpvReplaySubtitle: params.mpvReplaySubtitle,
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
mpvSendCommand: params.mpvSendCommand, mpvSendCommand: params.mpvSendCommand,
resolveProxyCommandOsd: params.resolveProxyCommandOsd, resolveProxyCommandOsd: params.resolveProxyCommandOsd,
isMpvConnected: params.isMpvConnected, isMpvConnected: params.isMpvConnected,
+2 -3
View File
@@ -17,10 +17,10 @@ export interface MpvCommandFromIpcRuntimeDeps {
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
showPlaybackFeedback?: (text: string) => void; showPlaybackFeedback?: (text: string) => void;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
playNextSubtitle: () => void; playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
getMpvClient: () => MpvPropertyClientLike | null; getMpvClient: () => MpvPropertyClientLike | null;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
@@ -42,11 +42,10 @@ export function handleMpvCommandFromIpcRuntime(
openPlaylistBrowser: deps.openPlaylistBrowser, openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
showRawMpvOsd: deps.showRawMpvOsd,
showPlaybackFeedback: deps.showPlaybackFeedback, showPlaybackFeedback: deps.showPlaybackFeedback,
mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvReplaySubtitle: deps.replayCurrentSubtitle,
mpvPlayNextSubtitle: deps.playNextSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle,
shiftSubDelayToAdjacentSubtitle: (direction) =>
deps.shiftSubDelayToAdjacentSubtitle(direction),
mpvSendCommand: deps.sendMpvCommand, mpvSendCommand: deps.sendMpvCommand,
resolveProxyCommandOsd: (nextCommand) => resolveProxyCommandOsd: (nextCommand) =>
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient), resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
+28 -17
View File
@@ -7,6 +7,10 @@ function readMainSource(): string {
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8'); return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
} }
function readSource(relPath: string): string {
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
}
test('manual watched session action starts immersion tracker before marking watched', () => { test('manual watched session action starts immersion tracker before marking watched', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -91,7 +95,7 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
}); });
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => { test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
const dismissBlock = source.match( const dismissBlock = source.match(
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/, /function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
@@ -99,7 +103,7 @@ test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', ()
assert.ok(dismissBlock); assert.ok(dismissBlock);
assert.match( assert.match(
dismissBlock, dismissBlock,
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/, /sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
); );
}); });
@@ -146,7 +150,7 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
}); });
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => { test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
const actionBlock = source.match( const actionBlock = source.match(
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/, /async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
@@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/, /const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
); );
assert.ok( assert.ok(
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') < actionBlock.indexOf('deps.initSubtitlePrefetch(') <
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'), actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
); );
}); });
test('update overlay notification action triggers install flow', () => { test('update overlay notification action triggers install flow', () => {
const source = readMainSource(); const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
assert.match( assert.match(
source, source,
@@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/); assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
assert.match(source, /installWhenAvailable:\s*true/); assert.match(source, /installWhenAvailable:\s*true/);
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/); assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/); assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
assert.match( assert.match(
source, runtimeSource,
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
);
assert.match(
runtimeSource,
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/, /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
); );
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/); assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
}); });
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => { test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
@@ -203,7 +211,7 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
}); });
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => { test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
const actionBlock = source.match( const actionBlock = source.match(
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/, /function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
@@ -346,7 +354,7 @@ test('warm tokenization release can signal readiness before the first subtitle a
}); });
test('stats server Yomitan note creation honors configured Anki server override policy', () => { test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/stats-server-runtime.ts');
const startStatsServerBlock = source.match( const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/, /statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body; )?.groups?.body;
@@ -357,7 +365,7 @@ test('stats server Yomitan note creation honors configured Anki server override
assert.ok(addYomitanNoteBlock); assert.ok(addYomitanNoteBlock);
assert.match( assert.match(
addYomitanNoteBlock, addYomitanNoteBlock,
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/, /const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
); );
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
@@ -365,10 +373,11 @@ test('stats server Yomitan note creation honors configured Anki server override
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource(); const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match( const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/, /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body; )?.groups?.body;
const resetBlock = source.match( const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/, /function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
@@ -459,7 +468,7 @@ test('manual visible overlay hide dismisses loading OSD', () => {
}); });
test('configured overlay notifications require visible ready overlay window', () => { test('configured overlay notifications require visible ready overlay window', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
const readinessBlock = source.match( const readinessBlock = source.match(
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/, /function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
@@ -469,7 +478,7 @@ test('configured overlay notifications require visible ready overlay window', ()
assert.ok(readinessBlock); assert.ok(readinessBlock);
assert.ok(statusBlock); assert.ok(statusBlock);
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/); assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/); assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/); assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/); assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
@@ -498,7 +507,8 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => { test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
const source = readMainSource(); const source = readMainSource();
const resetBlock = source.match( const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/, /function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
const setBlock = source.match( const setBlock = source.match(
@@ -509,6 +519,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.ok(setBlock); assert.ok(setBlock);
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/); assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
@@ -516,7 +527,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
}); });
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => { test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
const source = readMainSource(); const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
const afterBoundsBlock = source.match( const afterBoundsBlock = source.match(
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/, /afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body; )?.groups?.body;
+39
View File
@@ -0,0 +1,39 @@
const PASSWORD_STORE_ARG = '--password-store';
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
export function getPasswordStoreArg(argv: string[]): string | null {
let resolved: string | null = null;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
continue;
}
if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1];
if (value && !value.startsWith('--')) {
resolved = value.trim();
i += 1;
}
continue;
}
const [prefix, value] = arg.split('=', 2);
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
resolved = value.trim();
}
}
return resolved;
}
export function normalizePasswordStoreArg(value: string): string {
const normalized = value.trim();
if (normalized.toLowerCase() === 'gnome') {
return DEFAULT_LINUX_PASSWORD_STORE;
}
return normalized;
}
export function getDefaultPasswordStore(): string {
return DEFAULT_LINUX_PASSWORD_STORE;
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setMpvCurrentSecondarySubText } from './autoplay-subtitle-priming-runtime';
test('setMpvCurrentSecondarySubText uses client setter when available', () => {
const calls: string[] = [];
const client = {
currentSecondarySubText: '',
setCurrentSecondarySubText: (text: string) => {
calls.push(text);
},
};
setMpvCurrentSecondarySubText(client, 'secondary');
assert.deepEqual(calls, ['secondary']);
assert.equal(client.currentSecondarySubText, '');
});
test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => {
const client = {
currentSecondarySubText: '',
};
setMpvCurrentSecondarySubText(client, 'secondary');
assert.equal(client.currentSecondarySubText, 'secondary');
});
@@ -0,0 +1,272 @@
import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
type AutoplaySubtitlePrimingMpvClient = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string;
currentTimePos?: number;
currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
};
type AutoplaySubtitlePrimingPrefetchService = {
pause: () => void;
onSeek: (timePos: number) => void;
};
export interface AutoplaySubtitlePrimingRuntimeDeps {
getCurrentMediaPath: () => string | null | undefined;
getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null;
setCurrentSubText: (text: string) => void;
getCurrentSubText: () => string;
getCurrentSubtitleData: () => SubtitleData | null;
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
subtitleProcessingController: {
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (text: string) => void;
};
emitSubtitlePayload: (payload: SubtitleData) => void;
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
getLastObservedTimePos: () => number;
getVisibleOverlayVisible: () => boolean;
emitSecondarySubtitle: (text: string) => void;
initSubtitlePrefetch: (
sourcePath: string,
currentTimePos: number,
sourceKey?: string,
) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
logDebug: (message: string) => void;
}
export function setMpvCurrentSecondarySubText(
client: Pick<
AutoplaySubtitlePrimingMpvClient,
'currentSecondarySubText' | 'setCurrentSecondarySubText'
>,
text: string,
): void {
if (typeof client.setCurrentSecondarySubText === 'function') {
client.setCurrentSecondarySubText(text);
return;
}
client.currentSecondarySubText = text;
}
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
const { subtitleProcessingController, emitSubtitlePayload } = deps;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
null;
function getCurrentAutoplayMediaPath(): string | null {
return (
deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null
);
}
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
return getCurrentAutoplayMediaPath() === mediaPath;
}
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
return false;
}
autoplaySubtitlePrimedMediaPath = mediaPath;
return true;
}
function resetAutoplaySubtitlePrime(): void {
autoplaySubtitlePrimedMediaPath = null;
}
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
return false;
}
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
return false;
}
deps.setCurrentSubText(text);
deps.getSubtitlePrefetchService()?.pause();
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
if (cachedPayload) {
subtitleProcessingController.onSubtitleChange(text);
emitSubtitlePayload(cachedPayload);
return true;
}
subtitleProcessingController.onSubtitleChange(text);
return true;
}
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
return;
}
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
deps.logDebug(
`[autoplay-subtitle-prime] failed to read sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
});
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
emitAutoplayPrimedSubtitle(mediaPath, text);
}
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => deps.getMpvClient(),
setCurrentSubText: (text) => {
deps.setCurrentSubText(text);
},
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
onSubtitleChange: (text) => {
deps.getSubtitlePrefetchService()?.pause();
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
subtitleProcessingController.onSubtitleChange(text);
},
refreshCurrentSubtitle: (text) => {
deps.getSubtitlePrefetchService()?.pause();
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
subtitleProcessingController.refreshCurrentSubtitle(text);
},
deferUncachedRefresh: true,
emitSubtitle: (payload) => emitSubtitlePayload(payload),
setCurrentSecondarySubText: (text) => {
const client = deps.getMpvClient();
if (client) {
setMpvCurrentSecondarySubText(client, text);
}
},
emitSecondarySubtitle: (text) => {
deps.emitSecondarySubtitle(text);
},
logDebug: (message) => {
deps.logDebug(message);
},
});
}
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
return;
}
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
}
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
return;
}
if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) {
return;
}
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
if (!deps.getVisibleOverlayVisible()) {
return;
}
const text = deps.getCurrentSubText();
if (!text.trim()) {
return;
}
deps.getSubtitlePrefetchService()?.pause();
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
subtitleProcessingController.refreshCurrentSubtitle(text);
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
}
async function primeAutoplaySubtitleFromParsedCues(
mediaPath: string,
cues: SubtitleCue[],
): Promise<void> {
if (
cues.length === 0 ||
autoplaySubtitlePrimedMediaPath === mediaPath ||
!isCurrentAutoplayMediaPath(mediaPath)
) {
return;
}
const client = deps.getMpvClient();
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
);
const cue = selectAutoplayStartupCue(
cues,
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
);
if (!cue) {
return;
}
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
}
function clearScheduledSubtitlePrefetchRefresh(): void {
if (subtitlePrefetchRefreshTimer) {
clearTimeout(subtitlePrefetchRefreshTimer);
subtitlePrefetchRefreshTimer = null;
}
}
async function refreshSubtitleSidebarFromSource(
sourcePath: string,
mediaPath?: string,
): Promise<void> {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
await deps.initSubtitlePrefetch(
normalizedSourcePath,
deps.getLastObservedTimePos(),
normalizedSourcePath,
);
deps.setActiveParsedSubtitleMediaPath(nextMediaPath);
}
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchRefreshTimer = setTimeout(() => {
subtitlePrefetchRefreshTimer = null;
void deps.refreshSubtitlePrefetchFromActiveTrack();
}, delayMs);
}
return {
getCurrentAutoplayMediaPath,
resetAutoplaySubtitlePrime,
primeCurrentSubtitleForAutoplay,
primeCurrentSubtitleForVisibleOverlay,
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
primeAutoplaySubtitleFromParsedCues,
clearScheduledSubtitlePrefetchRefresh,
refreshSubtitleSidebarFromSource,
scheduleSubtitlePrefetchRefresh,
};
}
@@ -17,7 +17,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
playNextSubtitle: () => {}, playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
getMpvClient: () => null, getMpvClient: () => null,
isMpvConnected: () => false, isMpvConnected: () => false,
@@ -7,6 +7,7 @@ import {
detectInstalledFirstRunPlugin, detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
filterLegacyMpvPluginFileCandidates,
removeLegacyMpvPluginCandidates, removeLegacyMpvPluginCandidates,
resolvePackagedFirstRunPluginAssets, resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath, resolvePackagedRuntimePluginPath,
@@ -220,6 +221,20 @@ test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without v
}); });
}); });
test('filterLegacyMpvPluginFileCandidates keeps only legacy file candidates', () => {
assert.deepEqual(
filterLegacyMpvPluginFileCandidates([
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
]),
[
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
],
);
});
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => { test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
const calls: string[] = []; const calls: string[] = [];
const result = await removeLegacyMpvPluginCandidates({ const result = await removeLegacyMpvPluginCandidates({
@@ -180,6 +180,12 @@ export function detectInstalledFirstRunPluginCandidates(options: {
return candidates; return candidates;
} }
export function filterLegacyMpvPluginFileCandidates(
candidates: InstalledFirstRunPluginCandidate[],
): InstalledFirstRunPluginCandidate[] {
return candidates.filter((candidate) => candidate.kind === 'file');
}
function parseInstalledPluginVersion(content: string): string | null { function parseInstalledPluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/); const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null; return match?.[1] ?? null;
@@ -58,8 +58,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
@@ -101,8 +101,6 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.anilistStatus || args.anilistStatus ||
args.anilistLogout || args.anilistLogout ||
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildFfmpegSubtitleExtractionArgs } from './internal-subtitle-extraction';
test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => {
assert.throws(
() => buildFfmpegSubtitleExtractionArgs('/tmp/video.mkv', 2, '/tmp/subtitle-output'),
/outputPath.*file extension/,
);
});
@@ -0,0 +1,123 @@
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
import { codecToExtension } from '../../subsync/utils';
export async function loadSubtitleSourceText(source: string): Promise<string> {
if (/^https?:\/\//i.test(source)) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(source, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to download subtitle source (${response.status})`);
}
return await response.text();
} finally {
clearTimeout(timeoutId);
}
}
const filePath = resolveSubtitleSourcePath(source);
return fs.promises.readFile(filePath, 'utf8');
}
export type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
selected?: unknown;
external?: unknown;
codec?: unknown;
'ff-index'?: unknown;
'external-filename'?: unknown;
};
export function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
export function buildFfmpegSubtitleExtractionArgs(
videoPath: string,
ffIndex: number,
outputPath: string,
): string[] {
const outputFormat = path.extname(outputPath).slice(1);
if (!outputFormat) {
throw new Error(`outputPath must include a file extension for ffmpeg format: ${outputPath}`);
}
return [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
'-map',
`0:${ffIndex}`,
'-f',
outputFormat,
outputPath,
];
}
export async function extractInternalSubtitleTrackToTempFile(
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined);
if (ffIndex === null || extension === null) {
return null;
}
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
try {
await new Promise<void>((resolve, reject) => {
const child = spawn(
ffmpegPath,
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
);
let stderr = '';
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
});
});
} catch (error) {
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
return {
path: outputPath,
cleanup: async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
},
};
}
@@ -20,7 +20,6 @@ test('ipc bridge action main deps builders map callbacks', async () => {
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
playNextSubtitle: () => {}, playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
getMpvClient: () => null, getMpvClient: () => null,
isMpvConnected: () => true, isMpvConnected: () => true,
@@ -17,7 +17,6 @@ test('handle mpv command handler forwards command and built deps', () => {
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
playNextSubtitle: () => {}, playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
getMpvClient: () => null, getMpvClient: () => null,
isMpvConnected: () => true, isMpvConnected: () => true,
@@ -16,12 +16,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
}, },
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`), showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
replayCurrentSubtitle: () => calls.push('replay'), replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('next'), playNextSubtitle: () => calls.push('next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`), sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
getMpvClient: () => ({ connected: true, requestProperty: async () => null }), getMpvClient: () => ({ connected: true, requestProperty: async () => null }),
isMpvConnected: () => true, isMpvConnected: () => true,
@@ -35,10 +33,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
void deps.openPlaylistBrowser(); void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello'); deps.showMpvOsd('hello');
deps.showRawMpvOsd?.('delay');
deps.showPlaybackFeedback?.('primary'); deps.showPlaybackFeedback?.('primary');
deps.replayCurrentSubtitle(); deps.replayCurrentSubtitle();
deps.playNextSubtitle(); deps.playNextSubtitle();
void deps.shiftSubDelayToAdjacentSubtitle('next');
deps.sendMpvCommand(['show-text', 'ok']); deps.sendMpvCommand(['show-text', 'ok']);
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function'); assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
assert.equal(deps.isMpvConnected(), true); assert.equal(deps.isMpvConnected(), true);
@@ -50,10 +48,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'youtube-picker', 'youtube-picker',
'playlist-browser', 'playlist-browser',
'osd:hello', 'osd:hello',
'raw-osd:delay',
'feedback:primary', 'feedback:primary',
'replay', 'replay',
'next', 'next',
'shift:next',
'cmd:show-text:ok', 'cmd:show-text:ok',
]); ]);
}); });
@@ -5,6 +5,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
) { ) {
return (): MpvCommandFromIpcRuntimeDeps => { return (): MpvCommandFromIpcRuntimeDeps => {
const showPlaybackFeedback = deps.showPlaybackFeedback; const showPlaybackFeedback = deps.showPlaybackFeedback;
const showRawMpvOsd = deps.showRawMpvOsd;
return { return {
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
@@ -13,13 +14,12 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
openPlaylistBrowser: () => deps.openPlaylistBrowser(), openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text), showMpvOsd: (text: string) => deps.showMpvOsd(text),
...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}),
...(showPlaybackFeedback ...(showPlaybackFeedback
? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) } ? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) }
: {}), : {}),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
playNextSubtitle: () => deps.playNextSubtitle(), playNextSubtitle: () => deps.playNextSubtitle(),
shiftSubDelayToAdjacentSubtitle: (direction) =>
deps.shiftSubDelayToAdjacentSubtitle(direction),
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
getMpvClient: () => deps.getMpvClient(), getMpvClient: () => deps.getMpvClient(),
isMpvConnected: () => deps.isMpvConnected(), isMpvConnected: () => deps.isMpvConnected(),
+62
View File
@@ -0,0 +1,62 @@
import { app, dialog, shell } from 'electron';
import * as os from 'os';
import { exportLogsArchive } from './log-export';
export interface LogExportTrayRuntimeDeps {
flushMpvLog: () => Promise<void>;
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
}
export function createLogExportTrayRuntime(deps: LogExportTrayRuntimeDeps): {
exportLogsFromTray: () => Promise<void>;
} {
function describeUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function exportLogsFromTray(): Promise<void> {
try {
await deps.flushMpvLog();
} catch (error) {
deps.logWarn('Failed to flush mpv log before exporting logs from tray.', error);
}
try {
const result = exportLogsArchive({
platform: process.platform,
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
});
deps.logInfo(
`Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`,
);
void dialog
.showMessageBox({
type: 'info',
title: 'SubMiner logs exported',
message: 'SubMiner log export created.',
detail: result.zipPath,
buttons: ['OK', 'Show in Folder'],
defaultId: 0,
cancelId: 0,
})
.then((response) => {
if (response.response === 1) {
shell.showItemInFolder(result.zipPath);
}
});
} catch (error) {
const message = describeUnknownError(error);
deps.logWarn('Failed to export logs from tray.', error);
void dialog.showMessageBox({
type: 'error',
title: 'SubMiner log export failed',
message: 'Could not export SubMiner logs.',
detail: message,
});
}
}
return { exportLogsFromTray };
}
@@ -0,0 +1,319 @@
import { type BrowserWindow, screen } from 'electron';
import type { WindowGeometry } from '../../types';
import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement';
import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds';
import {
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
syncOverlayWindowLayer,
} from '../../core/services/overlay-window';
import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js';
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive';
import {
shouldExitFullscreenOverrideForTrackedGeometry,
type LinuxVisibleOverlayWindowMode,
} from './linux-visible-overlay-window-mode';
import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateVisibleOverlayBoundsHandler,
hasLiveOverlayWindowBoundsMismatch,
} from './overlay-window-layout';
import {
createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
} from './overlay-window-layout-main-deps';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
export interface OverlayGeometryRuntimeDeps {
overlayManager: {
getMainWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
};
getTrackedWindowGeometry: () => WindowGeometry | null;
getTrackedWindowMediaSourceId: () => string | null | undefined;
getTrackedWindowNativeId: () => string | null | undefined;
getStatsOverlayVisible: () => boolean;
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
getLinuxTrackedMpvFullscreen: () => boolean;
getLinuxTrackedMpvFullscreenChangedAtMs: () => number;
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) => void;
getLinuxVisibleOverlayOwnerBindingKey: () => string | null;
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
clearVisibleOverlayX11OwnerBinding: (window: BrowserWindow) => void;
getNativeWindowHandleDecimal: (window: BrowserWindow) => string;
enqueueVisibleOverlayX11OwnerBindingOperation: (
window: BrowserWindow,
args: string[],
onError?: (error: Error) => void,
) => void;
scheduleWindowsVisibleOverlayZOrderSyncBurst: () => void;
logDebug: (message: string, ...args: unknown[]) => void;
}
export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
const { overlayManager } = deps;
let lastOverlayWindowGeometry: WindowGeometry | null = null;
function getOverlayGeometryFallback(): WindowGeometry {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const bounds = display.workArea;
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
};
}
function getCurrentOverlayGeometry(): WindowGeometry {
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
const trackerGeometry = deps.getTrackedWindowGeometry();
if (trackerGeometry) return trackerGeometry;
return getOverlayGeometryFallback();
}
function getCurrentTrackedOverlayGeometry(): WindowGeometry | null {
return deps.getTrackedWindowGeometry();
}
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
function applyOverlayRegions(geometry: WindowGeometry): void {
lastOverlayWindowGeometry = geometry;
maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry);
overlayManager.setOverlayWindowBounds(geometry);
overlayManager.setModalWindowBounds(geometry);
}
function shouldExitLinuxFullscreenOverrideForGeometry(geometry: WindowGeometry): boolean {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
return false;
}
if (
deps.getLinuxTrackedMpvFullscreenChangedAtMs() > 0 &&
Date.now() - deps.getLinuxTrackedMpvFullscreenChangedAtMs() <
LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS
) {
return false;
}
const displayBounds = screen.getDisplayMatching(geometry).bounds;
return shouldExitFullscreenOverrideForTrackedGeometry({
currentMode: deps.getLinuxVisibleOverlayWindowMode(),
trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(),
geometry,
displayBounds,
});
}
function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeometry): void {
if (!shouldExitLinuxFullscreenOverrideForGeometry(geometry)) {
return;
}
deps.logDebug(
'Tracked mpv geometry no longer covers its display; exiting Linux fullscreen overlay override',
);
deps.syncLinuxVisibleOverlayMpvFullscreenMode(false);
}
function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean {
if (process.platform !== 'linux') {
return false;
}
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => {
if (!window || window.isDestroyed()) {
return false;
}
return hasHyprlandWindowPlacementBoundsMismatch({
title: window.getTitle(),
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
});
});
}
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
shouldRefreshUnchangedGeometry: (geometry) =>
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
(process.platform === 'linux' &&
(hasLiveOverlayWindowBoundsMismatch(
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
geometry,
) ||
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
if (process.platform === 'win32') {
deps.scheduleWindowsVisibleOverlayZOrderSyncBurst();
return;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (process.platform === 'linux') {
restoreLinuxOverlayWindowShape(mainWindow);
}
ensureOverlayWindowLevel(mainWindow);
},
});
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
updateVisibleOverlayBoundsMainDeps,
);
const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({
shouldSuppressOverlayWindowLevel: (window) => {
const mainWindow = overlayManager.getMainWindow();
return (
(deps.getStatsOverlayVisible() && window === mainWindow) ||
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window,
mainWindow,
separateWindows: deps.getOverlayForegroundSeparateWindows(),
})
);
},
ensureOverlayWindowLevelCore: (window) =>
ensureOverlayWindowLevelCore(window as BrowserWindow),
afterEnsureOverlayWindowLevel: () => {
const mainWindow = overlayManager.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
}
promoteStatsOverlayAbovePlayback();
},
});
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
ensureOverlayWindowLevelMainDeps,
);
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
syncOverlayWindowLayer(mainWindow, layer);
}
function moveVisibleOverlayAboveTrackedPlaybackWindow(window: BrowserWindow): void {
if (process.platform !== 'linux') return;
if (window !== overlayManager.getMainWindow()) return;
bindVisibleOverlayToTrackedX11Window(window);
const mediaSourceId = deps.getTrackedWindowMediaSourceId();
if (!mediaSourceId) return;
try {
window.moveAbove(mediaSourceId);
} catch (error) {
deps.logDebug(
'Failed to move visible overlay above tracked playback window:',
error instanceof Error ? error.message : String(error),
);
}
}
function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void {
const targetWindowId = deps.getTrackedWindowNativeId();
if (!targetWindowId) {
if (deps.getLinuxVisibleOverlayOwnerBindingKey() !== null) {
deps.clearVisibleOverlayX11OwnerBinding(window);
}
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
return;
}
const overlayWindowId = deps.getNativeWindowHandleDecimal(window);
const bindingKey = `${overlayWindowId}:${targetWindowId}`;
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
return;
}
deps.setLinuxVisibleOverlayOwnerBindingKey(bindingKey);
deps.enqueueVisibleOverlayX11OwnerBindingOperation(
window,
[
'-id',
overlayWindowId,
'-f',
'WM_TRANSIENT_FOR',
'32x',
'-set',
'WM_TRANSIENT_FOR',
targetWindowId,
],
(error) => {
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
}
deps.logDebug(
'Failed to bind visible overlay as transient for tracked X11 playback window:',
error instanceof Error ? error.message : String(error),
);
},
);
}
const buildEnforceOverlayLayerOrderMainDepsHandler =
createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) =>
params.ensureOverlayWindowLevel(window as BrowserWindow),
}),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
});
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
enforceOverlayLayerOrderMainDeps,
);
return {
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
resetLastOverlayWindowGeometry: () => {
lastOverlayWindowGeometry = null;
},
getOverlayGeometryFallback,
getCurrentOverlayGeometry,
getCurrentTrackedOverlayGeometry,
geometryMatches,
applyOverlayRegions,
shouldExitLinuxFullscreenOverrideForGeometry,
maybeExitLinuxFullscreenOverrideForTrackedGeometry,
hasHyprlandOverlayWindowPlacementMismatch,
moveVisibleOverlayAboveTrackedPlaybackWindow,
bindVisibleOverlayToTrackedX11Window,
syncPrimaryOverlayWindowLayer,
updateVisibleOverlayBounds,
ensureOverlayWindowLevel,
enforceOverlayLayerOrder,
};
}
export type OverlayGeometryRuntime = ReturnType<typeof createOverlayGeometryRuntime>;
@@ -0,0 +1,253 @@
import type { BrowserWindow } from 'electron';
import type {
NotificationType,
OverlayNotificationEventPayload,
OverlayNotificationPayload,
ResolvedConfig,
} from '../../types';
import type { AnkiIntegration } from '../../anki-integration';
import type { RuntimeOptionsManager } from '../../runtime-options';
import { AnkiConnectClient } from '../../anki-connect';
import { DEFAULT_CONFIG } from '../../config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { showDesktopNotification } from '../../core/utils';
import {
isOverlayWindowContentReady,
sendMpvCommandRuntime,
type MpvIpcClient,
} from '../../core/services';
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
import { createMaybeStartOverlayLoadingOsdHandler } from './overlay-loading-osd-start';
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
import {
getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions,
} from './configured-status-notification';
import { resolveOverlayReadinessNotificationType } from './notification-routing';
export interface OverlayNotificationsRuntimeDeps {
getResolvedConfig: () => ResolvedConfig;
getMainOverlayWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
showMpvOsd: (message: string) => void;
getMpvClient: () => MpvIpcClient | null;
getAnkiIntegration: () => AnkiIntegration | null;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
}
export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRuntimeDeps): {
isVisibleOverlayContentReady: () => boolean;
getConfiguredStatusNotificationType: () => NotificationType;
flushQueuedOverlayNotifications: () => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification: (id: string) => void;
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
toggleNotificationHistoryPanel: () => void;
showConfiguredStatusNotification: (
message: string,
options?: ConfiguredStatusNotificationOptions,
) => void;
showConfiguredPlaybackFeedback: (
message: string,
options?: ConfiguredStatusNotificationOptions,
) => void;
showSubsyncStatusNotification: (message: string) => void;
showYoutubeFlowStatusNotification: (message: string) => void;
showOverlayLoadingStatusNotification: () => void;
dismissOverlayLoadingStatusNotification: () => void;
maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void;
} {
function isVisibleOverlayContentReady(): boolean {
const overlayWindow = deps.getMainOverlayWindow();
return Boolean(
deps.getVisibleOverlayVisible() &&
overlayWindow &&
isOverlayWindowReadyForNotification(overlayWindow),
);
}
function getConfiguredStatusNotificationType(): NotificationType {
const configuredType = deps.getResolvedConfig().ankiConnect.behavior.notificationType;
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
}
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
return false;
}
if (window.webContents.isLoading()) {
return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
}
const overlayNotificationDelivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
send: (payload) => {
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
},
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
});
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
null;
function flushQueuedOverlayNotifications(): void {
overlayNotificationDelivery.flush();
}
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
overlayNotificationDelivery.send(payload);
}
function showOverlayNotification(payload: OverlayNotificationPayload): void {
sendOverlayNotificationEvent(
withConfiguredOverlayNotificationPosition(payload, deps.getResolvedConfig()),
);
}
function dismissOverlayNotification(id: string): void {
sendOverlayNotificationEvent({ id, dismiss: true });
}
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
const activeIntegrationOpen = deps.getAnkiIntegration()?.openNoteInAnki(noteId);
if (activeIntegrationOpen) {
await activeIntegrationOpen;
return;
}
const resolvedConfig = deps.getResolvedConfig();
const effectiveAnkiConfig =
deps.getRuntimeOptionsManager()?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const fallbackClient = new AnkiConnectClient(
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
);
await fallbackClient.openNoteInBrowser(noteId);
}
function toggleNotificationHistoryPanel(): void {
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
}
function showConfiguredStatusNotification(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
notifyConfiguredStatus(
message,
{
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
isOverlayReady: () => isVisibleOverlayContentReady(),
showOsd: (text) => deps.showMpvOsd(text),
showOverlayNotification,
showDesktopNotification: (title, notificationOptions) =>
showDesktopNotification(title, notificationOptions),
},
options,
);
}
function showConfiguredPlaybackFeedback(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
showConfiguredStatusNotification(message, {
...getPlaybackFeedbackNotificationOptions(message),
...options,
delivery: 'feedback',
});
}
function showSubsyncStatusNotification(message: string): void {
const syncing = message.startsWith('Subsync: syncing');
const failed = message.toLowerCase().includes('failed');
showConfiguredStatusNotification(message, {
id: 'subsync-status',
title: 'Subsync',
variant: failed ? 'error' : syncing ? 'progress' : 'info',
persistent: syncing,
desktop: !syncing,
});
}
function showYoutubeFlowStatusNotification(message: string): void {
const progress =
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
showConfiguredStatusNotification(message, {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
});
}
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
if (!overlayLoadingOsdController) {
overlayLoadingOsdController = createOverlayLoadingOsdController({
showOsd: (message) => {
deps.showMpvOsd(message);
},
clearOsd: () => {
sendMpvCommandRuntime(deps.getMpvClient(), ['show-text', '', '1']);
},
setInterval: (callback, delayMs) => {
const timer = setInterval(callback, delayMs);
timer.unref?.();
return timer;
},
clearInterval: (timer) => {
clearInterval(timer as ReturnType<typeof setInterval>);
},
});
}
return overlayLoadingOsdController;
}
function showOverlayLoadingStatusNotification(): void {
getOverlayLoadingOsdController().start();
}
function dismissOverlayLoadingStatusNotification(): void {
getOverlayLoadingOsdController().stop();
sendMpvCommandRuntime(deps.getMpvClient(), [
'script-message',
'subminer-overlay-loading-ready',
]);
dismissOverlayNotification('overlay-loading-status');
}
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(),
isOverlayContentReady: () => isVisibleOverlayContentReady(),
startOverlayLoadingOsd: () => {
showOverlayLoadingStatusNotification();
},
});
return {
isVisibleOverlayContentReady,
getConfiguredStatusNotificationType,
flushQueuedOverlayNotifications,
showOverlayNotification,
dismissOverlayNotification,
openAnkiCardFromNotification,
toggleNotificationHistoryPanel,
showConfiguredStatusNotification,
showConfiguredPlaybackFeedback,
showSubsyncStatusNotification,
showYoutubeFlowStatusNotification,
showOverlayLoadingStatusNotification,
dismissOverlayLoadingStatusNotification,
maybeStartOverlayLoadingOsd,
};
}
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { CompiledSessionBinding, ResolvedConfig } from '../../types';
import { createSessionBindingsRuntime } from './session-bindings-runtime';
test('persistSessionBindings logs and does not publish bindings when artifact write fails', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
const configDir = path.join(root, 'config-file');
fs.writeFileSync(configDir, 'not a directory');
const calls: string[] = [];
const runtime = createSessionBindingsRuntime({
configDir,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () =>
({
stats: { toggleKey: 's', markWatchedKey: 'w' },
}) as ResolvedConfig,
getMpvClient: () => null,
setSessionBindings: () => calls.push('setSessionBindings'),
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
logWarn: (message) => calls.push(`warn:${message}`),
});
try {
assert.throws(
() => runtime.persistSessionBindings([] as CompiledSessionBinding[]),
/ENOTDIR|EEXIST/,
);
assert.deepEqual(calls, ['warn:[session-bindings] Failed to write session bindings artifact']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,80 @@
import { sendMpvCommandRuntime, type MpvRuntimeClientLike } from '../../core/services';
import {
buildPluginSessionBindingsArtifact,
compileSessionBindings,
} from '../../core/services/session-bindings';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
export interface SessionBindingsRuntimeDeps {
configDir: string;
getKeybindings: () => Keybinding[];
getConfiguredShortcuts: () => ConfiguredShortcuts;
getResolvedConfig: () => ResolvedConfig;
getMpvClient: () => MpvRuntimeClientLike | null;
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string) => void;
}
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
persistSessionBindings: (
bindings: CompiledSessionBinding[],
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
) => void;
refreshCurrentSessionBindings: () => void;
} {
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
function compileCurrentSessionBindings(): {
bindings: CompiledSessionBinding[];
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
} {
return compileSessionBindings({
keybindings: deps.getKeybindings(),
shortcuts: deps.getConfiguredShortcuts(),
statsToggleKey: deps.getResolvedConfig().stats.toggleKey,
statsMarkWatchedKey: deps.getResolvedConfig().stats.markWatchedKey,
platform: resolveSessionBindingPlatform(),
rawConfig: deps.getResolvedConfig(),
});
}
function persistSessionBindings(
bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void {
const artifact = buildPluginSessionBindingsArtifact({
bindings,
warnings,
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
});
try {
writeSessionBindingsArtifact(deps.configDir, artifact);
} catch (error) {
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
throw error;
}
deps.setSessionBindings(bindings);
deps.setSessionBindingsInitialized(true);
const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) {
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
}
}
function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
deps.logWarn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings);
}
return { persistSessionBindings, refreshCurrentSessionBindings };
}
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isSelfOwnedBackgroundStatsDaemonState,
shouldClearAppStateStatsServerOnStop,
} from './stats-server-runtime';
test('detects self-owned background stats daemon state', () => {
assert.equal(
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
true,
);
});
test('stats server app-state reference should be cleared after private server stop', () => {
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
});
+260
View File
@@ -0,0 +1,260 @@
import path from 'node:path';
import type { BrowserWindow } from 'electron';
import {
addYomitanNoteViaSearch,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
} from '../../core/services';
import { startStatsServer } from '../../core/services/stats-server';
import { createLogger } from '../../logger';
import type { ResolvedConfig } from '../../types/config';
import type { AppState } from '../state';
import {
isBackgroundStatsServerProcessAlive,
readBackgroundStatsServerState,
removeBackgroundStatsServerState,
resolveBackgroundStatsServerUrl,
writeBackgroundStatsServerState,
} from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
export function isSelfOwnedBackgroundStatsDaemonState(state: {
pid: number;
port?: number;
startedAtMs?: number;
}): boolean {
return state.pid === process.pid;
}
export function shouldClearAppStateStatsServerOnStop(options: {
hadStatsServer: boolean;
}): boolean {
return options.hadStatsServer;
}
export interface StatsServerRuntimeDeps {
userDataPath: string;
statsDistPath: string;
getResolvedConfig: () => ResolvedConfig;
getImmersionTracker: () => AppState['immersionTracker'];
setAppStateStatsServer: (server: AppState['statsServer']) => void;
getMpvSocketPath: () => AppState['mpvSocketPath'];
getYomitanExt: () => AppState['yomitanExt'];
getYomitanSession: () => AppState['yomitanSession'];
getYomitanParserWindow: () => AppState['yomitanParserWindow'];
setYomitanParserWindow: (w: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise'];
setYomitanParserReadyPromise: (p: Promise<void> | null) => void;
getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise'];
setYomitanParserInitPromise: (p: Promise<boolean> | null) => void;
getYomitanAnkiDeckName: () => Promise<string>;
getAnilistRateLimiter: () => NonNullable<
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
>;
resolveAnkiNoteId: (noteId: number) => number;
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
ensureImmersionTrackerStarted: () => void;
setStatsStartupInProgress: (inProgress: boolean) => void;
}
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
stopStatsServer: () => void;
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
} {
let statsServer: ReturnType<typeof startStatsServer> | null = null;
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
function readLiveBackgroundStatsDaemonState(): {
pid: number;
port: number;
startedAtMs: number;
} | null {
const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return null;
}
if (state.pid === process.pid && !statsServer) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return null;
}
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return null;
}
return state;
}
function clearOwnedBackgroundStatsDaemonState(): void {
const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (state?.pid === process.pid) {
removeBackgroundStatsServerState(statsDaemonStatePath);
}
}
function stopStatsServer(): void {
if (!statsServer) {
return;
}
statsServer.close();
statsServer = null;
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
deps.setAppStateStatsServer(null);
}
clearOwnedBackgroundStatsDaemonState();
}
const startLocalStatsServer = (): void => {
const tracker = deps.getImmersionTracker();
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!statsServer) {
const yomitanDeps = {
getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession(),
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (w: BrowserWindow | null) => {
deps.setYomitanParserWindow(w);
},
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
deps.setYomitanParserReadyPromise(p);
},
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
deps.setYomitanParserInitPromise(p);
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
statsServer = startStatsServer({
port: deps.getResolvedConfig().stats.serverPort,
staticDir: deps.statsDistPath,
tracker,
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
mpvSocketPath: deps.getMpvSocketPath(),
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSecondarySubtitleLanguages: () =>
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
anilistRateLimiter: deps.getAnilistRateLimiter(),
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
}
return result.noteId;
},
});
deps.setAppStateStatsServer(statsServer);
}
deps.setAppStateStatsServer(statsServer);
};
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
currentPid: process.pid,
readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
removeBackgroundState: () => {
removeBackgroundStatsServerState(statsDaemonStatePath);
},
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
hasLocalStatsServer: () => statsServer !== null,
startLocalStatsServer,
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
});
const ensureBackgroundStatsServerStarted = (): {
url: string;
runningInCurrentProcess: boolean;
} => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== process.pid) {
return {
url: resolveBackgroundStatsServerUrl(liveDaemon),
runningInCurrentProcess: false,
};
}
deps.setStatsStartupInProgress(true);
try {
deps.ensureImmersionTrackerStarted();
} finally {
deps.setStatsStartupInProgress(false);
}
const port = deps.getResolvedConfig().stats.serverPort;
const result = ensureStatsServerStarted();
if (result.source === 'local') {
writeBackgroundStatsServerState(statsDaemonStatePath, {
pid: process.pid,
port,
startedAtMs: Date.now(),
});
}
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
};
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
try {
process.kill(state.pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
throw new Error(
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
);
}
throw error;
}
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('Timed out stopping background stats server.');
};
return {
stopStatsServer,
ensureStatsServerStarted,
ensureBackgroundStatsServerStarted,
stopBackgroundStatsServer,
};
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
test('runSupportAssetUpdatesForLauncherResult logs support-asset errors and preserves launcher result', async () => {
const warnings: string[] = [];
const launcherResult = { status: 'updated' } as const;
const result = await runSupportAssetUpdatesForLauncherResult({
launcherResult,
updateSupportAssets: async () => {
throw new Error('archive failed');
},
logWarn: (message, details) => {
warnings.push(`${message}:${details instanceof Error ? details.message : String(details)}`);
},
});
assert.equal(result, launcherResult);
assert.deepEqual(warnings, ['Support asset update failed after launcher update:archive failed']);
});
test('runSupportAssetUpdatesForLauncherResult uses support asset description in skip warnings', async () => {
const warnings: string[] = [];
const launcherResult = { status: 'updated' } as const;
const result = await runSupportAssetUpdatesForLauncherResult({
launcherResult,
assetDescription: 'Support asset update',
updateSupportAssets: async () => [
{ status: 'protected', command: 'install-theme' },
{ status: 'hash-mismatch', message: 'checksum failed' },
],
logWarn: (message) => {
warnings.push(message);
},
});
assert.equal(result, launcherResult);
assert.deepEqual(warnings, [
'Support asset update requires manual command: install-theme',
'Support asset update skipped: checksum failed',
]);
});
@@ -0,0 +1,191 @@
import { app, dialog } from 'electron';
import { execFile } from 'node:child_process';
import path from 'node:path';
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
import type { OverlayNotificationPayload } from '../../../types/notification';
import { createElectronAppUpdater, isNativeUpdaterSupported } from './app-updater';
import { createCurlFetch, createGlobalFetch } from './fetch-adapter';
import { createCurlHttpExecutor } from './curl-http-executor';
import { createFetchHttpExecutor } from './fetch-http-executor';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseSha256Sums,
type GitHubRelease,
} from './release-assets';
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
import { updateLauncherFromRelease } from './launcher-updater';
import { notifyUpdateAvailable } from './update-notifications';
import { createUpdateDialogPresenter } from './update-dialogs';
import { createFileUpdateStateStore, createUpdateService } from './update-service';
import { updateSupportAssetsFromRelease } from './support-assets';
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
export interface UpdateServiceRuntimeDeps {
userDataPath: string;
getUpdatesConfig: () => Required<UpdatesConfig>;
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
showMpvOsd: (message: string) => void;
withStatsWindowLayerSuspendedForNativeDialog: <T>(showDialog: () => Promise<T>) => Promise<T>;
}
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
getUpdateService: () => ReturnType<typeof createUpdateService>;
} {
const updateStateStore = createFileUpdateStateStore(
path.join(deps.userDataPath, 'update-state.json'),
);
let updateService: ReturnType<typeof createUpdateService> | null = null;
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return createCurlHttpExecutor();
}
function getFetchForUpdater() {
if (process.platform === 'win32') return globalFetchForUpdater;
return curlFetch;
}
async function updateLauncherFromSelectedRelease(
launcherPath?: string,
channel: UpdateChannel = deps.getUpdatesConfig().channel,
release: GitHubRelease | null = null,
) {
const fetchForUpdater = getFetchForUpdater();
if (!release) {
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
}
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
if (!sumsAsset) {
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
}
const sums = parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
);
const launcherResult = await updateLauncherFromRelease({
release,
sha256Sums: sums,
launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
return runSupportAssetUpdatesForLauncherResult({
launcherResult,
assetDescription: 'Support asset update',
updateSupportAssets: () =>
updateSupportAssetsFromRelease({
release,
sha256Sums: sums,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
}),
logWarn: (message, details) => deps.logWarn(message, details),
});
}
function getUpdateService() {
if (updateService) return updateService;
const appUpdater = createElectronAppUpdater({
currentVersion: app.getVersion(),
isPackaged: app.isPackaged,
log: (message) => deps.logInfo(message),
getChannel: () => deps.getUpdatesConfig().channel,
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
env: process.env,
log: (message) => deps.logWarn(message),
}),
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: async () => {
if (process.platform !== 'darwin') {
app.focus({ steal: true });
return;
}
try {
await app.dock?.show();
} catch (error) {
deps.logWarn('Failed to show macOS dock before update dialog', error);
}
// app.focus({ steal: true }) alone does not reliably activate the process
// when SubMiner was reached via `subminer -u` (single-instance forwarding
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
// which is the only path that reliably brings the running app forward.
await new Promise<void>((resolve) => {
execFile(
'/usr/bin/osascript',
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
{ timeout: 2000 },
(error) => {
if (error) {
deps.logWarn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
withStatsWindowLayerSuspended: (showDialog) =>
deps.withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
getConfig: () => deps.getUpdatesConfig(),
getCurrentVersion: () => app.getVersion(),
now: () => Date.now(),
readState: () => updateStateStore.readState(),
writeState: (state) => updateStateStore.writeState(state),
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel, release) =>
updateLauncherFromSelectedRelease(launcherPath, channel, release),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
showUpdateAvailableDialog: (version) =>
updateDialogPresenter.showUpdateAvailableDialog(version),
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
showManualUpdateRequiredDialog: (version) =>
updateDialogPresenter.showManualUpdateRequiredDialog(version),
downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) =>
notifyUpdateAvailable(
{ notificationType: deps.getUpdatesConfig().notificationType, version },
{
showSystemNotification: (title, body) => deps.showDesktopNotification(title, { body }),
showOverlayNotification: (payload) => deps.showOverlayNotification(payload),
showOsdNotification: (message) => {
deps.showMpvOsd(message);
},
log: (message) => deps.logWarn(message),
},
),
log: (message) => deps.logWarn(message),
});
return updateService;
}
return { getUpdateService };
}
@@ -0,0 +1,24 @@
export async function runSupportAssetUpdatesForLauncherResult<
TLauncherResult,
TSupportResult extends { status: string; command?: string; message?: string },
>(options: {
launcherResult: TLauncherResult;
assetDescription?: string;
updateSupportAssets: () => Promise<TSupportResult[]>;
logWarn: (message: string, details?: unknown) => void;
}): Promise<TLauncherResult> {
const assetDescription = options.assetDescription ?? 'Support asset update';
try {
const supportResults = await options.updateSupportAssets();
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
options.logWarn(`${assetDescription} requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
options.logWarn(`${assetDescription} skipped: ${result.message ?? result.status}`);
}
}
} catch (error) {
options.logWarn('Support asset update failed after launcher update', error);
}
return options.launcherResult;
}
@@ -0,0 +1,810 @@
import { type BrowserWindow, screen } from 'electron';
import { execFile } from 'node:child_process';
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
import type { BaseWindowTracker } from '../../window-trackers';
import {
bindWindowsOverlayAboveMpv,
clearWindowsOverlayOwner,
findWindowsMpvTargetWindowHandle,
getWindowsForegroundProcessName,
setWindowsOverlayOwner,
} from '../../window-trackers/windows-helper';
import {
applyLinuxOverlayInputShape,
applyLinuxOverlayPointerInteractionMousePassthrough,
ensureLinuxOverlayPointerInteractionLoop,
type ForegroundSuppressionGraceState,
mapOverlayMeasurementForPointerInteraction,
resolveForegroundSuppressionWithGrace,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction,
} from './linux-overlay-pointer-interaction';
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
import {
ensureLinuxOverlayZOrderKeepAliveLoop,
shouldRunLinuxOverlayZOrderKeepAlive,
tickLinuxOverlayZOrderKeepAlive,
} from './linux-overlay-zorder-keepalive';
import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
import { hasLiveSeparateWindow } from './settings-window-z-order';
export interface VisibleOverlayInteractionRuntimeDeps {
overlayManager: {
getMainWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
};
overlayContentMeasurementStore: {
clear: (layer: 'visible') => void;
getLatestByLayer: (layer: 'visible') => OverlayContentMeasurement | null;
};
logger: {
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug: (message: string, ...args: unknown[]) => void;
};
updateVisibleOverlayVisibility: () => void;
getModalInputExclusive: () => boolean;
getStatsOverlayVisible: () => boolean;
setStatsOverlayVisible: (visible: boolean) => void;
getWindowTracker: () => BaseWindowTracker | null;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
getMpvSocketPath: () => string;
getBackendOverride: () => string | null;
getInitialArgs: () => CliArgs | null;
getOverlayRuntimeInitialized: () => boolean;
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
bindVisibleOverlayToTrackedX11Window: (window: BrowserWindow) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
refreshCurrentSubtitle: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
resetLastOverlayWindowGeometry: () => void;
enforceOverlayLayerOrder: () => void;
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
}
export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInteractionRuntimeDeps) {
const { overlayManager, overlayContentMeasurementStore, logger } = deps;
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500;
// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = {
lossSinceMs: null,
};
let visibleOverlayInteractionActive = false;
let linuxOverlayInputShapeActive = false;
let linuxVisibleOverlayStartupInputPrimed = false;
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
// moves off measured subtitle/sidebar rects onto the popup.
let linuxOverlayInteractiveHint = false;
let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise<void>>();
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => {
deps.setStatsOverlayVisible(visible);
},
resetVisibleOverlayInteraction: () => {
visibleOverlayInteractionActive = false;
},
getMainWindow: () => overlayManager.getMainWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
});
function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false;
linuxOverlayInputShapeActive = false;
resetLinuxVisibleOverlayStartupInputPrimer();
linuxOverlayInteractiveHint = false;
overlayContentMeasurementStore.clear('visible');
const mainWindow = overlayManager.getMainWindow();
if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) {
restoreLinuxOverlayWindowShape(mainWindow);
}
}
function restoreVisibleOverlayWindowShapeForShow(): void {
if (process.platform !== 'linux') {
return;
}
restoreLinuxOverlayWindowShape(overlayManager.getMainWindow());
}
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
visibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayZOrderRetryTimeouts = [];
}
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
if (token !== macOSVisibleOverlayForegroundProbeToken) {
return;
}
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
macOSVisibleOverlayForegroundProbeTimeout = null;
}
if (!macOSVisibleOverlayForegroundProbeActive) {
return;
}
macOSVisibleOverlayForegroundProbeActive = false;
deps.updateVisibleOverlayVisibility();
}
function startMacOSVisibleOverlayForegroundProbe(): void {
if (process.platform !== 'darwin') {
return;
}
const tracker = deps.getWindowTracker();
if (!tracker) {
return;
}
macOSVisibleOverlayForegroundProbeActive = true;
const token = ++macOSVisibleOverlayForegroundProbeToken;
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
}
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
void tracker
.refreshNow()
.catch((error) => {
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
})
.finally(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
});
}
function getNativeWindowHandleDecimal(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
? handle.readBigUInt64LE(0).toString()
: BigInt(handle.readUInt32LE(0)).toString();
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
return getNativeWindowHandleDecimal(window);
}
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
const handle = window.getNativeWindowHandle();
return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
}
function enqueueVisibleOverlayX11OwnerBindingOperation(
window: BrowserWindow,
args: string[],
onError?: (error: Error) => void,
): void {
const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve();
const operation = previous
.catch(() => {})
.then(
() =>
new Promise<void>((resolve) => {
if (window.isDestroyed()) {
resolve();
return;
}
execFile('xprop', args, { timeout: 1500 }, (error) => {
if (error) {
onError?.(error);
}
resolve();
});
}),
);
const queued = operation.finally(() => {
if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) {
linuxVisibleOverlayOwnerBindingQueues.delete(window);
}
});
linuxVisibleOverlayOwnerBindingQueues.set(window, queued);
}
function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void {
if (window.isDestroyed()) return;
enqueueVisibleOverlayX11OwnerBindingOperation(window, [
'-id',
getNativeWindowHandleDecimal(window),
'-remove',
'WM_TRANSIENT_FOR',
]);
}
function resolveWindowsOverlayBindTargetHandle(
targetMpvSocketPath?: string | null,
): number | null {
if (process.platform !== 'win32') {
return null;
}
try {
if (targetMpvSocketPath) {
const windowTracker = deps.getWindowTracker() as {
getTargetWindowHandle?: () => number | null;
} | null;
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
return trackedHandle;
}
return null;
}
return findWindowsMpvTargetWindowHandle();
} catch {
return null;
}
}
function createOverlayWindowTracker(
override?: string | null,
targetMpvSocketPath?: string | null,
) {
const initialArgs = deps.getInitialArgs();
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
}
function bindVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'linux') {
deps.bindVisibleOverlayToTrackedX11Window(mainWindow);
return;
}
if (process.platform !== 'win32') return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetSocketPath = deps.getMpvSocketPath();
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
return;
}
if (targetSocketPath) {
return;
}
const tracker = deps.getWindowTracker();
const mpvResult = tracker
? (() => {
try {
const win32 =
require('../../window-trackers/win32') as typeof import('../../window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
}
function releaseVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow();
if (process.platform === 'linux') {
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
if (mainWindow && !mainWindow.isDestroyed()) {
clearVisibleOverlayX11OwnerBinding(mainWindow);
}
return;
}
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwner(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
}
function startOverlayWindowTrackerForCurrentSocket(): void {
startOverlayWindowTrackerCore({
backendOverride: deps.getBackendOverride(),
getMpvSocketPath: () => deps.getMpvSocketPath(),
createWindowTracker: createOverlayWindowTracker,
setWindowTracker: (tracker) => {
deps.setWindowTracker(tracker);
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => {
deps.refreshCurrentSubtitle();
},
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
bindOverlayOwner: () => bindVisibleOverlayOwner(),
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
});
}
function retargetOverlayWindowTrackerForMpvSocket(
nextSocketPath: string,
previousSocketPath: string,
): void {
if (nextSocketPath === previousSocketPath || !deps.getOverlayRuntimeInitialized()) {
return;
}
const previousTracker = deps.getWindowTracker();
if (previousTracker) {
try {
previousTracker.stop();
} catch (error) {
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
}
}
releaseVisibleOverlayOwner();
deps.setWindowTracker(null);
deps.setTrackerNotReadyWarningShown(false);
deps.resetLastOverlayWindowGeometry();
startOverlayWindowTrackerForCurrentSocket();
deps.updateVisibleOverlayVisibility();
deps.syncOverlayShortcuts();
logger.info(
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
);
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible() ||
!overlayManager.getVisibleOverlayVisible()
) {
return false;
}
const windowTracker = deps.getWindowTracker();
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
return false;
}
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(deps.getMpvSocketPath());
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
return false;
}
function requestWindowsVisibleOverlayZOrderSync(): void {
if (process.platform !== 'win32') {
return;
}
if (windowsVisibleOverlayZOrderSyncInFlight) {
windowsVisibleOverlayZOrderSyncQueued = true;
return;
}
windowsVisibleOverlayZOrderSyncInFlight = true;
void syncWindowsVisibleOverlayToMpvZOrder()
.catch((error) => {
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
})
.finally(() => {
windowsVisibleOverlayZOrderSyncInFlight = false;
if (!windowsVisibleOverlayZOrderSyncQueued) {
return;
}
windowsVisibleOverlayZOrderSyncQueued = false;
requestWindowsVisibleOverlayZOrderSync();
});
}
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
if (process.platform !== 'win32') {
return;
}
clearWindowsVisibleOverlayZOrderRetryTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
const retryTimeout = setTimeout(() => {
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
(timeout) => timeout !== retryTimeout,
);
requestWindowsVisibleOverlayZOrderSync();
}, delayMs);
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
}
}
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
return (
process.platform === 'win32' &&
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
);
}
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return false;
}
const windowTracker = deps.getWindowTracker();
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
const overlayFocused = mainWindow.isFocused();
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
return !overlayFocused && !trackerFocused;
}
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
lastWindowsVisibleOverlayForegroundProcessName = null;
return;
}
const processName = getWindowsForegroundProcessName();
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
if (normalizedProcessName !== previousProcessName) {
deps.updateVisibleOverlayVisibility();
}
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
requestWindowsVisibleOverlayZOrderSync();
}
}
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
return;
}
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
if (windowsVisibleOverlayForegroundPollInterval === null) {
return;
}
clearInterval(windowsVisibleOverlayForegroundPollInterval);
windowsVisibleOverlayForegroundPollInterval = null;
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
startMacOSVisibleOverlayForegroundProbe();
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
deps.updateVisibleOverlayVisibility();
}, delayMs);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
function getLinuxOverlayPointerMeasurement() {
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
return mapOverlayMeasurementForPointerInteraction(measurement);
}
function shouldSuspendLinuxOverlayPointerInteraction(): boolean {
return deps.getModalInputExclusive() || deps.getStatsOverlayVisible();
}
function shouldSuppressLinuxOverlayPointerInteraction(): boolean {
return resolveForegroundSuppressionWithGrace({
hasForegroundSeparateWindow: hasLiveSeparateWindow(
deps.getOverlayForegroundSeparateWindows(),
),
isTrackingMpvWindow: Boolean(deps.getWindowTracker()?.isTracking()),
isMpvWindowFocused: deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true,
nowMs: Date.now(),
graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS,
state: linuxPointerForegroundSuppressionGrace,
});
}
function shouldUseLinuxOverlayInputShape(): boolean {
// Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so
// it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped
// region). There is no input-only region API on Linux, so selective hit-testing is handled by
// the main-process cursor poll instead. Keep this off to avoid clipping the overlay.
return false;
}
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
return (
process.platform === 'linux' &&
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
);
}
function clearLinuxVisibleOverlayStartupInputGrace(): void {
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
}
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
linuxVisibleOverlayStartupInputPrimed = false;
clearLinuxVisibleOverlayStartupInputGrace();
}
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
if (!shouldUseLinuxOverlayInputShape()) {
linuxOverlayInputShapeActive = false;
return false;
}
const result = applyLinuxOverlayInputShape({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
});
linuxOverlayInputShapeActive = result.active;
return result.handled;
}
function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
visibleOverlayInteractionActive = active;
if (
process.platform === 'linux' &&
applyLinuxOverlayPointerInteractionMousePassthrough({
active,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
})
) {
return;
}
deps.updateVisibleOverlayVisibility();
}
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
if (process.platform !== 'linux') return;
if (linuxVisibleOverlayStartupInputPrimed) return;
if (shouldUseLinuxOverlayInputShape()) return;
if (
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
})
) {
return;
}
linuxVisibleOverlayStartupInputPrimed = true;
linuxVisibleOverlayStartupInputGraceUntilMs =
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
updateLinuxOverlayPointerInteractionActive(true);
}
const linuxOverlayZOrderKeepAliveDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
isTrackingMpvWindow: () => Boolean(deps.getWindowTracker()?.isTracking()),
isMpvWindowFocused: () => deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true,
shouldSuppressReassert: () =>
deps.getModalInputExclusive() ||
deps.getStatsOverlayVisible() ||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows()) ||
(visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true),
raiseMpvWindow: () => {
if (
lastLinuxVisibleOverlayFollowedMpvAtMs > 0 &&
Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <=
LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
) {
return Promise.resolve(false);
}
lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now();
return deps.getWindowTracker()?.raiseTargetWindow?.() ?? Promise.resolve(false);
},
releaseOverlayLayerOrder: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.setAlwaysOnTop(false);
mainWindow.setFullScreen?.(false);
mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
if (
deps.getLinuxVisibleOverlayWindowMode() === 'fullscreen-override' &&
mainWindow.isVisible()
) {
mainWindow.hide();
}
},
enforceOverlayLayerOrder: () => {
deps.enforceOverlayLayerOrder();
},
focusOverlayWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return;
mainWindow.focus();
},
};
function requestLinuxOverlayZOrderFollow(): void {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) return;
void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => {
logger.debug(
'Failed to follow tracked mpv behind focused overlay:',
error instanceof Error ? error.message : String(error),
);
});
}
ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps);
const linuxOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () =>
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () =>
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
getInteractionActive: () => visibleOverlayInteractionActive,
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
};
function tickLinuxOverlayPointerInteractionNow(): void {
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
return;
}
tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps);
}
ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps);
return {
handleStatsOverlayVisibilityChanged,
resetVisibleOverlayInputState,
restoreVisibleOverlayWindowShapeForShow,
startMacOSVisibleOverlayForegroundProbe,
getNativeWindowHandleDecimal,
getWindowsNativeWindowHandle,
getWindowsNativeWindowHandleNumber,
enqueueVisibleOverlayX11OwnerBindingOperation,
clearVisibleOverlayX11OwnerBinding,
createOverlayWindowTracker,
bindVisibleOverlayOwner,
releaseVisibleOverlayOwner,
startOverlayWindowTrackerForCurrentSocket,
retargetOverlayWindowTrackerForMpvSocket,
requestWindowsVisibleOverlayZOrderSync,
scheduleWindowsVisibleOverlayZOrderSyncBurst,
hasWindowsVisibleOverlayFocusHandoffGrace,
ensureWindowsVisibleOverlayForegroundPollLoop,
clearWindowsVisibleOverlayForegroundPollLoop,
scheduleVisibleOverlayBlurRefresh,
getLinuxOverlayPointerMeasurement,
hasLinuxVisibleOverlayStartupInputGrace,
clearLinuxVisibleOverlayStartupInputGrace,
resetLinuxVisibleOverlayStartupInputPrimer,
applyLinuxOverlayInputShapeFromLatestMeasurement,
updateLinuxOverlayPointerInteractionActive,
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
requestLinuxOverlayZOrderFollow,
tickLinuxOverlayPointerInteractionNow,
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
setVisibleOverlayInteractionActive: (active: boolean) => {
visibleOverlayInteractionActive = active;
},
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
getLastWindowsVisibleOverlayForegroundProcessName: () =>
lastWindowsVisibleOverlayForegroundProcessName,
getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
setLinuxOverlayInteractiveHint: (interactive: boolean) => {
linuxOverlayInteractiveHint = interactive;
},
};
}
export type VisibleOverlayInteractionRuntime = ReturnType<
typeof createVisibleOverlayInteractionRuntime
>;
@@ -0,0 +1,125 @@
import { app, dialog, shell } from 'electron';
import * as os from 'os';
import {
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
filterLegacyMpvPluginFileCandidates,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
} from './first-run-setup-plugin';
export interface WindowsMpvPluginDetectionRuntimeDeps {
mainDirname: string;
logWarn: (message: string) => void;
}
export function createWindowsMpvPluginDetectionRuntime(
deps: WindowsMpvPluginDetectionRuntimeDeps,
): {
resolveBundledMpvRuntimePluginEntrypoint: () => string | undefined;
detectWindowsInstalledMpvPlugin: (
mpvExecutablePath: string,
) => ReturnType<typeof detectInstalledMpvPlugin>;
logInstalledMpvPluginDetected: (detection: {
path: string | null;
version: string | null;
}) => void;
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch: (
mpvPath: string,
detection: { path: string | null; version: string | null },
) => Promise<'removed' | 'continue' | 'cancel'>;
} {
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
dirname: deps.mainDirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
return detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath,
});
}
function logInstalledMpvPluginDetected(detection: {
path: string | null;
version: string | null;
}) {
if (!detection.path) return;
deps.logWarn(
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
);
}
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: [
'SubMiner detected an installed mpv plugin at:',
detection.path ?? 'unknown path',
'',
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
].join('\n'),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const result = await removeLegacyMpvPluginCandidates({
candidates: filterLegacyMpvPluginFileCandidates(
detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
),
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
return {
resolveBundledMpvRuntimePluginEntrypoint,
detectWindowsInstalledMpvPlugin,
logInstalledMpvPluginDetected,
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch,
};
}
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
assert.notEqual(
buildYomitanAnkiSettingsKey({
targetUrl: 'http://127.0.0.1:8766',
targetDeck: 'Mining',
forceOverride: false,
}),
buildYomitanAnkiSettingsKey({
targetUrl: 'http://127.0.0.1:8766',
targetDeck: 'Mining',
forceOverride: true,
}),
);
});
@@ -0,0 +1,76 @@
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
import type { ResolvedConfig } from '../../types';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
export interface YomitanAnkiServerSyncRuntimeDeps {
isExternalReadOnlyMode: () => boolean;
getResolvedConfig: () => ResolvedConfig;
getYomitanParserRuntimeDeps: () => Parameters<typeof syncYomitanDefaultAnkiServerCore>[1];
logError: (message: string, ...args: unknown[]) => void;
logInfo: (message: string, ...args: unknown[]) => void;
}
export function buildYomitanAnkiSettingsKey(options: {
targetUrl: string;
targetDeck: string;
forceOverride: boolean;
}): string {
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
}
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
syncYomitanDefaultProfileAnkiServer: () => Promise<void>;
} {
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
function getPreferredYomitanAnkiServerUrl(): string {
return getPreferredYomitanAnkiServerUrlRuntime(deps.getResolvedConfig().ankiConnect);
}
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
if (deps.isExternalReadOnlyMode()) {
return;
}
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
const forceOverride = ankiConnectConfig
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
: false;
const targetSettingsKey = buildYomitanAnkiSettingsKey({
targetUrl,
targetDeck,
forceOverride,
});
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
return;
}
const synced = await syncYomitanDefaultAnkiServerCore(
targetUrl,
deps.getYomitanParserRuntimeDeps(),
{
error: (message, ...args) => {
deps.logError(message, ...args);
},
info: (message, ...args) => {
deps.logInfo(message, ...args);
},
},
{
forceOverride,
deck: targetDeck,
},
);
if (synced) {
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
}
}
return { syncYomitanDefaultProfileAnkiServer };
}
-2
View File
@@ -980,8 +980,6 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
test('default keybindings dispatch through overlay keyboard handling', async () => { test('default keybindings dispatch through overlay keyboard handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();
const specialActionIds: Record<string, string> = { const specialActionIds: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
+6 -12
View File
@@ -96,18 +96,17 @@ function describeCommand(command: (string | number)[]): string {
if (command[1] < 0) return 'Jump to previous subtitle'; if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing'; return 'Reload current subtitle timing';
} }
if (first === 'sub-step' && typeof command[1] === 'number') {
if (command[1] > 0) return 'Shift subtitle delay to next cue';
if (command[1] < 0) return 'Shift subtitle delay to previous cue';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku'; if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':'); const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${ return `Cycle runtime option ${rawId || 'option'} ${
@@ -131,6 +130,7 @@ function sectionForCommand(command: (string | number)[]): string {
first === 'cycle' || first === 'cycle' ||
first === 'seek' || first === 'seek' ||
first === 'sub-seek' || first === 'sub-seek' ||
first === 'sub-step' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE || first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) { ) {
@@ -227,10 +227,6 @@ function describeSessionAction(
return 'Replay current subtitle'; return 'Replay current subtitle';
case 'playNextSubtitle': case 'playNextSubtitle':
return 'Play next subtitle'; return 'Play next subtitle';
case 'shiftSubDelayPrevLine':
return 'Shift subtitle delay to previous cue';
case 'shiftSubDelayNextLine':
return 'Shift subtitle delay to next cue';
case 'cycleRuntimeOption': case 'cycleRuntimeOption':
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${ return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next' payload?.direction === -1 ? 'previous' : 'next'
@@ -271,8 +267,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Modals and tools'; return 'Modals and tools';
case 'replayCurrentSubtitle': case 'replayCurrentSubtitle':
case 'playNextSubtitle': case 'playNextSubtitle':
case 'shiftSubDelayPrevLine':
case 'shiftSubDelayNextLine':
return 'Playback and navigation'; return 'Playback and navigation';
case 'cycleRuntimeOption': case 'cycleRuntimeOption':
return 'Runtime settings'; return 'Runtime settings';
+3 -7
View File
@@ -3,7 +3,6 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { createRendererState } from '../state.js'; import { createRendererState } from '../state.js';
import { import {
buildSessionHelpSections, buildSessionHelpSections,
@@ -17,13 +16,10 @@ test('session help describes sub-seek commands as subtitle-line navigation', ()
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle'); assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
}); });
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => { test('session help describes native subtitle-delay step commands separately from sub-seek', () => {
assert.equal(describeSessionHelpCommand(['sub-step', 1]), 'Shift subtitle delay to next cue');
assert.equal( assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]), describeSessionHelpCommand(['sub-step', -1]),
'Shift subtitle delay to next cue',
);
assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
'Shift subtitle delay to previous cue', 'Shift subtitle delay to previous cue',
); );
}); });
-2
View File
@@ -43,8 +43,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openPlaylistBrowser', 'openPlaylistBrowser',
'replayCurrentSubtitle', 'replayCurrentSubtitle',
'playNextSubtitle', 'playNextSubtitle',
'shiftSubDelayPrevLine',
'shiftSubDelayNextLine',
'cycleRuntimeOption', 'cycleRuntimeOption',
]; ];
-2
View File
@@ -25,8 +25,6 @@ export type SessionActionId =
| 'openPlaylistBrowser' | 'openPlaylistBrowser'
| 'replayCurrentSubtitle' | 'replayCurrentSubtitle'
| 'playNextSubtitle' | 'playNextSubtitle'
| 'shiftSubDelayPrevLine'
| 'shiftSubDelayNextLine'
| 'cycleRuntimeOption'; | 'cycleRuntimeOption';
export interface SessionKeySpec { export interface SessionKeySpec {
+19
View File
@@ -16,6 +16,7 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0", "@vitejs/plugin-react": "^4.4.0",
"happy-dom": "^20.10.2",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^6.3.0", "vite": "^6.3.0",
@@ -239,16 +240,24 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -291,6 +300,8 @@
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -307,6 +318,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"happy-dom": ["happy-dom@20.10.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -399,12 +412,18 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
+2 -1
View File
@@ -15,11 +15,12 @@
"recharts": "^2.15.0" "recharts": "^2.15.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0", "@vitejs/plugin-react": "^4.4.0",
"happy-dom": "^20.10.2",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^6.3.0" "vite": "^6.3.0"
} }
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import { AnilistSelector } from './AnilistSelector';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousISReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', {
value: previousWindow,
configurable: true,
});
Object.defineProperty(globalThis, 'document', {
value: previousDocument,
configurable: true,
});
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
};
}
function renderSelector(root: Root, props: { animeId: number; initialQuery: string }): void {
root.render(
<AnilistSelector
animeId={props.animeId}
initialQuery={props.initialQuery}
onClose={() => {}}
onLinked={() => {}}
/>,
);
}
function inputValue(container: Element): string {
const input = container.querySelector('input');
assert.ok(input);
return input.value;
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
test('AnilistSelector resyncs normalized query and searches when the initial anime changes', async () => {
const uninstallDom = installDom();
const originalSearchAnilist = apiClient.searchAnilist;
const secondSearch = deferred<Awaited<ReturnType<typeof apiClient.searchAnilist>>>();
const searchCalls: string[] = [];
apiClient.searchAnilist = (async (query: string) => {
searchCalls.push(query);
if (query === 'My Hero Academia') {
return secondSearch.promise;
}
return [
{
id: 1,
episodes: 1,
season: null,
seasonYear: null,
description: null,
coverImage: null,
title: { romaji: 'First Result', english: null, native: null },
},
];
}) as typeof apiClient.searchAnilist;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
renderSelector(root, { animeId: 1, initialQuery: 'Sword Art Online Season 1' });
});
assert.equal(inputValue(container), 'Sword Art Online');
assert.deepEqual(searchCalls, ['Sword Art Online']);
assert.match(container.textContent ?? '', /First Result/);
await act(async () => {
renderSelector(root, { animeId: 2, initialQuery: 'My Hero Academia: Season 3' });
});
assert.equal(inputValue(container), 'My Hero Academia');
assert.deepEqual(searchCalls, ['Sword Art Online', 'My Hero Academia']);
assert.doesNotMatch(container.textContent ?? '', /First Result/);
assert.match(container.textContent ?? '', /Searching/);
await act(async () => {
secondSearch.resolve([
{
id: 2,
episodes: 2,
season: null,
seasonYear: null,
description: null,
coverImage: null,
title: { romaji: 'Second Result', english: null, native: null },
},
]);
await secondSearch.promise;
});
assert.match(container.textContent ?? '', /Second Result/);
await act(async () => {
root.unmount();
});
} finally {
apiClient.searchAnilist = originalSearchAnilist;
uninstallDom();
}
});
+13 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query';
interface AnilistMedia { interface AnilistMedia {
id: number; id: number;
@@ -24,7 +25,7 @@ export function AnilistSelector({
onClose, onClose,
onLinked, onLinked,
}: AnilistSelectorProps) { }: AnilistSelectorProps) {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery));
const [results, setResults] = useState<AnilistMedia[]>([]); const [results, setResults] = useState<AnilistMedia[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null); const [linking, setLinking] = useState<number | null>(null);
@@ -33,17 +34,24 @@ export function AnilistSelector({
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
if (initialQuery) doSearch(initialQuery); const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery);
}, []); setQuery(normalizedInitialQuery);
setResults([]);
setLoading(false);
setLinking(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (normalizedInitialQuery) doSearch(normalizedInitialQuery);
}, [initialQuery, animeId]);
const doSearch = async (q: string) => { const doSearch = async (q: string) => {
if (!q.trim()) { const searchQuery = normalizeAnilistSearchQuery(q);
if (!searchQuery) {
setResults([]); setResults([]);
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const data = await apiClient.searchAnilist(q.trim()); const data = await apiClient.searchAnilist(searchQuery);
setResults(data); setResults(data);
} catch { } catch {
setResults([]); setResults([]);
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeAnilistSearchQuery } from './anilist-search-query';
test('normalizeAnilistSearchQuery removes appended season scope from anime titles', () => {
assert.equal(normalizeAnilistSearchQuery('Sword Art Online Season 1'), 'Sword Art Online');
assert.equal(normalizeAnilistSearchQuery('KonoSuba Season 02'), 'KonoSuba');
});
test('normalizeAnilistSearchQuery removes bracketed season scope without dropping real title text', () => {
assert.equal(normalizeAnilistSearchQuery('KonoSuba (Season 2)'), 'KonoSuba');
assert.equal(normalizeAnilistSearchQuery('KonoSuba - Season 2'), 'KonoSuba');
});
test('normalizeAnilistSearchQuery removes colon-delimited season scope from anime titles', () => {
assert.equal(normalizeAnilistSearchQuery('My Hero Academia: Season 3'), 'My Hero Academia');
assert.equal(normalizeAnilistSearchQuery('Title: Season 01'), 'Title');
});
test('normalizeAnilistSearchQuery keeps inputs when stripping season scope would erase title', () => {
assert.equal(normalizeAnilistSearchQuery('Season 1'), 'Season 1');
});
+9
View File
@@ -0,0 +1,9 @@
export function normalizeAnilistSearchQuery(query: string): string {
const trimmed = query.trim().replace(/\s+/g, ' ');
const withoutSeason = trimmed
.replace(/\s*[\[(]\s*Season\s+0?\d+\s*[\])]\s*$/i, '')
.replace(/\s*[-:]\s*Season\s+0?\d+\s*$/i, '')
.replace(/\s+Season\s+0?\d+\s*$/i, '')
.trim();
return withoutSeason.length > 0 ? withoutSeason : trimmed;
}