mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 03:13:39 -07:00
Replace subtitle delay actions with native mpv keybindings (#120)
This commit is contained in:
@@ -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.
|
||||||
+30
-4
@@ -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.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+95
-92
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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(':');
|
||||||
|
|||||||
@@ -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}');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-25
@@ -348,7 +348,6 @@ import {
|
|||||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||||
createConfigHotReloadRuntime,
|
createConfigHotReloadRuntime,
|
||||||
createDiscordPresenceService,
|
createDiscordPresenceService,
|
||||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
|
||||||
createFieldGroupingOverlayRuntime,
|
createFieldGroupingOverlayRuntime,
|
||||||
createOverlayContentMeasurementStore,
|
createOverlayContentMeasurementStore,
|
||||||
createOverlayManager,
|
createOverlayManager,
|
||||||
@@ -6838,26 +6837,6 @@ async function extractInternalSubtitleTrackToTempFile(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
|
||||||
getMpvClient: () => appState.mpvClient,
|
|
||||||
loadSubtitleSourceText,
|
|
||||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
|
||||||
onSubtitleDelayShifted: (delaySeconds) => {
|
|
||||||
const key = activeJellyfinSubtitleDelayKey;
|
|
||||||
if (!key) return;
|
|
||||||
const saved = saveJellyfinSubtitleDelay({
|
|
||||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
|
||||||
itemId: key.itemId,
|
|
||||||
streamIndex: key.streamIndex,
|
|
||||||
delaySeconds,
|
|
||||||
});
|
|
||||||
if (!saved) {
|
|
||||||
logger.warn('Failed to save Jellyfin subtitle delay.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||||
await dispatchSessionActionCore(request, {
|
await dispatchSessionActionCore(request, {
|
||||||
toggleStatsOverlay: () =>
|
toggleStatsOverlay: () =>
|
||||||
@@ -6905,8 +6884,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
|
||||||
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
|
||||||
cycleRuntimeOption: (id, direction) => {
|
cycleRuntimeOption: (id, direction) => {
|
||||||
if (!appState.runtimeOptionsManager) {
|
if (!appState.runtimeOptionsManager) {
|
||||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||||
@@ -6944,11 +6921,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||||
|
showRawMpvOsd: (text: string) => showMpvOsd(text),
|
||||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
|
||||||
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
|
||||||
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
||||||
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user