mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 15:13:32 -07:00
Compare commits
15 Commits
v0.16.0
...
split-main
| Author | SHA1 | Date | |
|---|---|---|---|
|
71d106b417
|
|||
|
05ac3a0382
|
|||
|
2c5a803839
|
|||
|
572bdd1cf7
|
|||
|
b9fe555b94
|
|||
|
8f362063dd
|
|||
|
eb1af727bb
|
|||
|
1fc83a842d
|
|||
|
a4edf53d21
|
|||
|
1a3944aa4f
|
|||
|
2d1b6cb78e
|
|||
|
0ef95cde09
|
|||
| 94a65416ae | |||
| 0a384a22c9 | |||
|
b3b45521b6
|
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: runtime
|
||||||
|
|
||||||
|
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||||
@@ -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
@@ -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.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|
||||||
|
|||||||
+97
-94
@@ -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.
|
||||||
@@ -571,26 +571,29 @@ 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 |
|
||||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||||
| `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 |
|
||||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
|
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||||
|
|
||||||
**Custom keybindings example:**
|
**Custom keybindings example:**
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -655,26 +658,26 @@ 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"`) |
|
||||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||||
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
|
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
|
||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||||
@@ -974,57 +977,57 @@ 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) |
|
||||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||||
|
|
||||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||||
|
|||||||
@@ -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.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+49
-46
@@ -43,31 +43,34 @@ 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) |
|
||||||
| `J` | Cycle primary subtitle track |
|
| `J` | Cycle primary subtitle track |
|
||||||
| `Shift+J` | Cycle secondary subtitle track |
|
| `Shift+J` | Cycle secondary subtitle track |
|
||||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
| `ArrowRight` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
| `ArrowLeft` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | Seek forward 60 seconds |
|
| `ArrowUp` | Seek forward 60 seconds |
|
||||||
| `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 |
|
||||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
| `z` | Shift subtitles 100 ms earlier |
|
||||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
| `Shift+Z` | Delay subtitles by 100 ms |
|
||||||
| `Q` | Quit mpv |
|
| `x` | Delay subtitles by 100 ms |
|
||||||
| `Ctrl+W` | Quit mpv |
|
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
| `Q` | Quit mpv |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
| `Ctrl+W` | Quit mpv |
|
||||||
|
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||||
|
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||||
|
| `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.
|
||||||
|
|
||||||
@@ -75,19 +78,19 @@ 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` |
|
||||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
||||||
|
|
||||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||||
|
|
||||||
@@ -108,17 +111,17 @@ 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 |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
| `y-h` | Open session help |
|
| `y-h` | Open session help |
|
||||||
|
|
||||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
|
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 `## 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.
|
- `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
|
||||||
|
|||||||
@@ -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,26 +391,34 @@ 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
|
||||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
if not is_supported_binding(binding) then
|
||||||
if key_names then
|
|
||||||
for key_index, key_name in ipairs(key_names) do
|
|
||||||
local name = "subminer-session-binding-"
|
|
||||||
.. tostring(generation)
|
|
||||||
.. "-"
|
|
||||||
.. tostring(index)
|
|
||||||
.. "-"
|
|
||||||
.. tostring(key_index)
|
|
||||||
next_binding_names[#next_binding_names + 1] = name
|
|
||||||
mp.add_forced_key_binding(key_name, name, function()
|
|
||||||
handle_binding(binding)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"warn",
|
"warn",
|
||||||
"session-bindings",
|
"session-bindings",
|
||||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
"Skipped unsupported session binding from artifact"
|
||||||
)
|
)
|
||||||
|
else
|
||||||
|
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||||
|
if key_names then
|
||||||
|
for key_index, key_name in ipairs(key_names) do
|
||||||
|
local name = "subminer-session-binding-"
|
||||||
|
.. tostring(generation)
|
||||||
|
.. "-"
|
||||||
|
.. tostring(index)
|
||||||
|
.. "-"
|
||||||
|
.. tostring(key_index)
|
||||||
|
next_binding_names[#next_binding_names + 1] = name
|
||||||
|
mp.add_forced_key_binding(key_name, name, function()
|
||||||
|
handle_binding(binding)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"session-bindings",
|
||||||
|
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|
||||||
## What’s 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
|
|
||||||
@@ -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, /## What’s 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, /## What’s 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, /What’s Changed/);
|
assert.doesNotMatch(changelog, /What's Changed|What’s 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`',
|
||||||
|
'',
|
||||||
|
'## What’s 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, /## What’s 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');
|
||||||
|
|||||||
@@ -433,12 +433,45 @@ function resolveContributionsForFragments(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWhatsChangedHeading(line: string): boolean {
|
||||||
|
return line === "## What's Changed" || line === '## What’s 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[] = ['## What’s 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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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] },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(':');
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
@@ -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}');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+338
-1999
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,15 +95,15 @@ 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;
|
||||||
|
|
||||||
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,9 +150,9 @@ 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;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -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,9 +211,9 @@ 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;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -346,18 +354,18 @@ 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;
|
||||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
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,11 +373,12 @@ 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;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -459,17 +468,17 @@ 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;
|
||||||
const statusBlock = source.match(
|
const statusBlock = source.match(
|
||||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
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,8 +507,9 @@ 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');
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
const resetBlock = runtimeSource.match(
|
||||||
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const setBlock = source.match(
|
const setBlock = source.match(
|
||||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
@@ -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,9 +527,9 @@ 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;
|
||||||
|
|
||||||
assert.ok(afterBoundsBlock);
|
assert.ok(afterBoundsBlock);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 +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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
|||||||
'openPlaylistBrowser',
|
'openPlaylistBrowser',
|
||||||
'replayCurrentSubtitle',
|
'replayCurrentSubtitle',
|
||||||
'playNextSubtitle',
|
'playNextSubtitle',
|
||||||
'shiftSubDelayPrevLine',
|
|
||||||
'shiftSubDelayNextLine',
|
|
||||||
'cycleRuntimeOption',
|
'cycleRuntimeOption',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ export type SessionActionId =
|
|||||||
| 'openPlaylistBrowser'
|
| 'openPlaylistBrowser'
|
||||||
| 'replayCurrentSubtitle'
|
| 'replayCurrentSubtitle'
|
||||||
| 'playNextSubtitle'
|
| 'playNextSubtitle'
|
||||||
| 'shiftSubDelayPrevLine'
|
|
||||||
| 'shiftSubDelayNextLine'
|
|
||||||
| 'cycleRuntimeOption';
|
| 'cycleRuntimeOption';
|
||||||
|
|
||||||
export interface SessionKeySpec {
|
export interface SessionKeySpec {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user