Replace subtitle delay actions with native mpv keybindings (#120)

This commit is contained in:
2026-06-12 00:03:06 -07:00
committed by GitHub
parent b3b45521b6
commit 0a384a22c9
41 changed files with 395 additions and 790 deletions
@@ -0,0 +1,4 @@
type: changed
area: overlay
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
+30 -4
View File
@@ -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
View File
@@ -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.
+30 -4
View File
@@ -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
View File
@@ -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
+34 -20
View File
@@ -270,10 +270,6 @@ function M.create(ctx)
return { "--replay-current-subtitle" } return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then elseif action_id == "playNextSubtitle" then
return { "--play-next-subtitle" } return { "--play-next-subtitle" }
elseif action_id == "shiftSubDelayPrevLine" then
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
elseif action_id == "cycleRuntimeOption" then elseif action_id == "cycleRuntimeOption" then
local runtime_option_id = payload and payload.runtimeOptionId or nil local runtime_option_id = payload and payload.runtimeOptionId or nil
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
@@ -350,6 +346,16 @@ function M.create(ctx)
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs) invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
end end
local function is_supported_binding(binding)
if binding.actionType == "mpv-command" then
return type(binding.command) == "table" and binding.command[1] ~= nil
end
if binding.actionType == "session-action" then
return build_cli_args(binding.actionId, binding.payload, binding.cliArgs) ~= nil
end
return false
end
local function load_artifact() local function load_artifact()
local artifact_path = environment.resolve_session_bindings_artifact_path() local artifact_path = environment.resolve_session_bindings_artifact_path()
local raw = read_file(artifact_path) local raw = read_file(artifact_path)
@@ -385,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
-80
View File
@@ -1,80 +0,0 @@
## Highlights
### Breaking Changes
- **Notification Type `both`**: This setting now routes to overlay + system notifications instead of mpv OSD + system.
- Set `notificationType` to `osd-system` in `config.jsonc` to keep the previous OSD + system behavior.
- `osd` and `osd-system` remain valid config-file values but no longer appear as options in the Settings UI.
### Added
- **Overlay Notifications**: A new in-app notification stack replaces bare OSD text for most alerts, using Catppuccin Macchiato styling with 3-second auto-dismiss.
- Position via `notifications.overlayPosition` (top-left, top-center, or top-right; default top-right). Startup, mining, sync, and error alerts queue for the overlay instead of falling back to raw OSD.
- Mined-card notifications include card thumbnails and an **Open in Anki** button; update-available notifications include a one-click **Update** button.
- **Notification History Panel**: A slide-in panel logging every notification from the current session, toggled with `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`).
- Works whether the overlay or mpv has focus; slides in from the same edge as the notification stack.
- Entries retain thumbnails and action buttons (Open in Anki, etc.) and can be removed individually or cleared all at once.
- **Stats Search**: A new Search tab for real-time subtitle sentence search across your library.
- Matches by headword with media context; mine directly to sentence cards, word cards, or audio cards.
- Sentence cards are queued before slow media generation finishes, so the card lands in Anki quickly with audio filled in later.
### Changed
- **AniSkip**: Intro detection now runs in the SubMiner app rather than the mpv plugin.
- Covers all files in the mpv session including playlist advances; the plugin no longer makes any network calls.
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` hot-reload without restarting playback. Requires SubMiner to be connected to mpv — plugin-only sessions no longer fetch skip windows.
- **Library**: Local and Jellyfin entries are now split by season using folder structure first, filename parsing as fallback.
- Existing combined-series stats rows are automatically migrated to season-specific entries on startup.
- Anime detail and cover art refresh immediately after manually changing an AniList entry.
- **Stats — Vocabulary Review**: Hide Known/Hide Kana filters are remembered across sessions; Related Seen Words now matches on shared readings or kanji; duplicate-collapsed exclusions cover all token variants.
- **Stats — Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
- **Stats — Library Browsing**: Remembers card size between sessions; retries stored cover art preserving PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; session deletes show progress and refresh faster.
- **Stats Mining**: Several reliability improvements when mining from Search and vocabulary examples.
- Empty `ankiConnect.deck` falls back to Yomitan's configured mining deck; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- Invalid stored timings and out-of-order subtitle pairs are skipped before FFmpeg runs; partial media failures are shown inline rather than silently dropped.
### Fixed
- **AniList**: Entries are now marked completed when a post-watch sync reaches the final known episode of the season.
- **AniSkip**: Fixed intro markers disappearing after same-media mpv reloads; fixed detection for intros starting at 0 seconds and common release-group filenames.
- **Jellyfin**: Session restarts after setup login so the websocket reconnects with fresh credentials; session stops on logout.
- **Anki — Sentence Cards**: Generated audio is written only to the configured sentence audio field and no longer also fills the expression audio field.
- **Stats Mining**: Word audio uses configured Yomitan sources; English subtitle text is no longer written to word cards; sentence clips correctly update the SentenceAudio field.
- **Overlay Startup**: Subtitle bars are hoverable and clickable as soon as the first subtitle line appears; Linux overlay input is primed from the first measured surface so first-line subtitles and startup notifications are immediately clickable; an OSD spinner now shows from mpv connect through to content-ready.
- **Startup Autoplay**: SubMiner now releases playback after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
<details>
<summary>Internal changes</summary>
### Internal
- Release notes now credit contributors with a What's Changed list and a New Contributors section, resolved from changelog fragments via git and the GitHub API.
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes and direct contributors to consolidate same-PR churn before merging.
</details>
## 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`.
+48 -4
View File
@@ -165,6 +165,46 @@ local ctx = {
actionType = "mpv-command", actionType = "mpv-command",
command = { "sub-seek", 1 }, command = { "sub-seek", 1 },
}, },
{
key = {
code = "ArrowLeft",
modifiers = { "ctrl", "shift" },
},
actionType = "mpv-command",
command = { "sub-step", -1 },
},
{
key = {
code = "ArrowRight",
modifiers = { "ctrl", "shift" },
},
actionType = "mpv-command",
command = { "sub-step", 1 },
},
{
key = {
code = "KeyZ",
modifiers = {},
},
actionType = "mpv-command",
command = { "add", "sub-delay", -0.1 },
},
{
key = {
code = "KeyZ",
modifiers = { "shift" },
},
actionType = "mpv-command",
command = { "add", "sub-delay", 0.1 },
},
{
key = {
code = "KeyX",
modifiers = {},
},
actionType = "mpv-command",
command = { "add", "sub-delay", 0.1 },
},
{ {
key = { key = {
code = "BracketRight", code = "BracketRight",
@@ -323,6 +363,11 @@ local expected_mpv_bindings = {
{ keys = "DOWN", command = { "seek", -60 } }, { keys = "DOWN", command = { "seek", -60 } },
{ keys = "H", command = { "sub-seek", -1 } }, { keys = "H", command = { "sub-seek", -1 } },
{ keys = "L", command = { "sub-seek", 1 } }, { keys = "L", command = { "sub-seek", 1 } },
{ keys = "Ctrl+Shift+LEFT", command = { "sub-step", -1 } },
{ keys = "Ctrl+Shift+RIGHT", command = { "sub-step", 1 } },
{ keys = "z", command = { "add", "sub-delay", -0.1 } },
{ keys = "Z", command = { "add", "sub-delay", 0.1 } },
{ keys = "x", command = { "add", "sub-delay", 0.1 } },
{ keys = "q", command = { "quit" } }, { keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } }, { keys = "Ctrl+w", command = { "quit" } },
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } }, { keys = "MBTN_BACK", command = { "sub-seek", -1 } },
@@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do
end end
local expected_cli_bindings = { local expected_cli_bindings = {
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
{ keys = "}", flag = "--shift-sub-delay-next-line" },
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" }, { keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" }, { keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" }, { keys = "Ctrl+H", flag = "--replay-current-subtitle" },
@@ -365,6 +406,9 @@ for _, expected in ipairs(expected_cli_bindings) do
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag) assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
end end
assert_true(find_binding("Shift+]") == nil, "retired subtitle delay action should not register Shift+]")
assert_true(find_binding("Shift+[") == nil, "retired subtitle delay action should not register Shift+[")
local play_next = find_binding("Ctrl+L") local play_next = find_binding("Ctrl+L")
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form") assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
+7 -4
View File
@@ -101,8 +101,6 @@ test('parseArgs captures session action forwarding flags', () => {
'--toggle-primary-subtitle-bar', '--toggle-primary-subtitle-bar',
'--replay-current-subtitle', '--replay-current-subtitle',
'--play-next-subtitle', '--play-next-subtitle',
'--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line',
'--cycle-runtime-option', '--cycle-runtime-option',
'anki.autoUpdateNewCards:prev', 'anki.autoUpdateNewCards:prev',
'--session-action', '--session-action',
@@ -120,8 +118,6 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.togglePrimarySubtitleBar, true); assert.equal(args.togglePrimarySubtitleBar, true);
assert.equal(args.replayCurrentSubtitle, true); assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true); assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1); assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' }); assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
@@ -131,6 +127,13 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true); assert.equal(shouldStartApp(args), true);
}); });
test('parseArgs ignores retired subtitle delay shift flags', () => {
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
assert.equal(hasExplicitCommand(args), false);
assert.equal(shouldStartApp(args), false);
});
test('parseArgs captures internal playback feedback command', () => { test('parseArgs captures internal playback feedback command', () => {
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']); const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
-16
View File
@@ -41,8 +41,6 @@ export interface CliArgs {
openPlaylistBrowser: boolean; openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean; replayCurrentSubtitle: boolean;
playNextSubtitle: boolean; playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
playbackFeedback?: string; playbackFeedback?: string;
cycleRuntimeOptionId?: string; cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1; cycleRuntimeOptionDirection?: 1 | -1;
@@ -149,8 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined, playbackFeedback: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
@@ -296,8 +292,6 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true; else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true; else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--playback-feedback=')) { else if (arg.startsWith('--playback-feedback=')) {
const value = arg.slice('--playback-feedback='.length).trim(); const value = arg.slice('--playback-feedback='.length).trim();
if (value) args.playbackFeedback = value; if (value) args.playbackFeedback = value;
@@ -562,8 +556,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
@@ -638,8 +630,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
@@ -705,8 +695,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
@@ -766,8 +754,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
@@ -832,8 +818,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
@@ -234,3 +234,16 @@ test('default keybindings include replay and next subtitle controls', () => {
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']); assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']); assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
}); });
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
const keybindingMap = new Map(
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
);
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
});
+5 -7
View File
@@ -55,8 +55,6 @@ export const SPECIAL_COMMANDS = {
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
} as const; } as const;
@@ -72,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'ArrowDown', command: ['seek', -60] }, { key: 'ArrowDown', command: ['seek', -60] },
{ key: 'Shift+KeyH', command: ['sub-seek', -1] }, { key: 'Shift+KeyH', command: ['sub-seek', -1] },
{ key: 'Shift+KeyL', command: ['sub-seek', 1] }, { key: 'Shift+KeyL', command: ['sub-seek', 1] },
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] }, { key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
{ { key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
key: 'Shift+BracketLeft', { key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], { key: 'Shift+KeyZ', command: ['add', 'sub-delay', 0.1] },
}, { key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, { key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] }, { key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
-2
View File
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
-2
View File
@@ -49,8 +49,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false, togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined, playbackFeedback: undefined,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
-12
View File
@@ -537,18 +537,6 @@ export function handleCliCommand(
'playNextSubtitle', 'playNextSubtitle',
'Play next subtitle failed', 'Play next subtitle failed',
); );
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.playbackFeedback) { } else if (args.playbackFeedback) {
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd; const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
showFeedback(args.playbackFeedback); showFeedback(args.playbackFeedback);
-1
View File
@@ -10,7 +10,6 @@ export {
unregisterOverlayShortcutsRuntime, unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut'; } from './overlay-shortcut';
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler'; export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command'; export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
export { export {
copyCurrentSubtitle, copyCurrentSubtitle,
+15 -11
View File
@@ -15,8 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
}, },
@@ -48,9 +46,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
mpvPlayNextSubtitle: () => { mpvPlayNextSubtitle: () => {
calls.push('next'); calls.push('next');
}, },
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
mpvSendCommand: (command) => { mpvSendCommand: (command) => {
sentCommands.push(command); sentCommands.push(command);
}, },
@@ -111,20 +106,29 @@ test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle tra
]); ]);
}); });
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => { test('handleMpvCommandFromIpc emits mpv OSD for subtitle delay keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions(); const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options); handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]); assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
assert.deepEqual(osd, []); assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']); assert.deepEqual(playbackFeedback, []);
}); });
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => { test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['sub-step', 1], options);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['sub-step', 1]]);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, []);
});
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
const { options, calls, sentCommands, osd } = createOptions(); const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__sub-delay-next-line'], options); handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
assert.deepEqual(calls, ['shift:next']); assert.deepEqual(calls, []);
assert.deepEqual(sentCommands, []); assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
assert.deepEqual(osd, []); assert.deepEqual(osd, []);
}); });
+22 -27
View File
@@ -13,8 +13,6 @@ export interface HandleMpvCommandFromIpcOptions {
RUNTIME_OPTION_CYCLE_PREFIX: string; RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string; REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string; PLAY_NEXT_SUBTITLE: string;
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string; YOUTUBE_PICKER_OPEN: string;
PLAYLIST_BROWSER_OPEN: string; PLAYLIST_BROWSER_OPEN: string;
}; };
@@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions {
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
showPlaybackFeedback?: (text: string) => void; showPlaybackFeedback?: (text: string) => void;
mpvReplaySubtitle: () => void; mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void; mpvPlayNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
mpvSendCommand: (command: (string | number)[]) => void; mpvSendCommand: (command: (string | number)[]) => void;
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>; resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
@@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([
'multiply', 'multiply',
]); ]);
function resolveProxyCommandOsdTemplate(command: (string | number)[]): string | null { interface ProxyCommandFeedback {
template: string;
rawMpvOsd: boolean;
}
function resolveProxyCommandOsdTemplate(command: (string | number)[]): ProxyCommandFeedback | null {
const operation = typeof command[0] === 'string' ? command[0] : ''; const operation = typeof command[0] === 'string' ? command[0] : '';
if (operation === 'sub-step') {
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
}
const property = typeof command[1] === 'string' ? command[1] : ''; const property = typeof command[1] === 'string' ? command[1] : '';
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null; if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
if (property === 'sub-pos') { if (property === 'sub-pos') {
return 'Subtitle position: ${sub-pos}'; return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false };
} }
if (property === 'sid') { if (property === 'sid') {
return 'Subtitle track: ${sid}'; return { template: 'Subtitle track: ${sid}', rawMpvOsd: false };
} }
if (property === 'secondary-sid') { if (property === 'secondary-sid') {
return 'Secondary subtitle track: ${secondary-sid}'; return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false };
} }
if (property === 'sub-delay') { if (property === 'sub-delay') {
return 'Subtitle delay: ${sub-delay}'; return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
} }
return null; return null;
} }
@@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd(
command: (string | number)[], command: (string | number)[],
options: HandleMpvCommandFromIpcOptions, options: HandleMpvCommandFromIpcOptions,
): void { ): void {
const template = resolveProxyCommandOsdTemplate(command); const feedback = resolveProxyCommandOsdTemplate(command);
if (!template) return; if (!feedback) return;
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd; const showFeedback = feedback.rawMpvOsd
? (options.showRawMpvOsd ?? options.showMpvOsd)
: (options.showPlaybackFeedback ?? options.showMpvOsd);
const emit = async () => { const emit = async () => {
try { try {
const resolved = await options.resolveProxyCommandOsd?.(command); const resolved = await options.resolveProxyCommandOsd?.(command);
showFeedback(resolved || template); showFeedback(resolved || feedback.template);
} catch { } catch {
showFeedback(template); showFeedback(feedback.template);
} }
}; };
@@ -118,20 +127,6 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
) {
const direction =
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START
? 'next'
: 'previous';
options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => {
options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`);
});
return;
}
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (!options.hasRuntimeOptionsManager()) return; if (!options.hasRuntimeOptionsManager()) return;
const [, idToken, directionToken] = first.split(':'); const [, idToken, directionToken] = first.split(':');
@@ -47,9 +47,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
}, },
replayCurrentSubtitle: () => calls.push('replay'), replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('play-next'), playNextSubtitle: () => calls.push('play-next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'), playNextPlaylistItem: () => calls.push('playlist-next'),
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
-7
View File
@@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps {
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>; openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
playNextSubtitle: () => void; playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void; playNextPlaylistItem: () => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
@@ -124,12 +123,6 @@ export async function dispatchSessionAction(
case 'playNextSubtitle': case 'playNextSubtitle':
deps.playNextSubtitle(); deps.playNextSubtitle();
return; return;
case 'shiftSubDelayPrevLine':
await deps.shiftSubDelayToAdjacentSubtitle('previous');
return;
case 'shiftSubDelayNextLine':
await deps.shiftSubDelayToAdjacentSubtitle('next');
return;
case 'cycleRuntimeOption': { case 'cycleRuntimeOption': {
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined; const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
if (!runtimeOptionId) { if (!runtimeOptionId) {
+23 -2
View File
@@ -287,8 +287,6 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => { test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
const expectedSpecialActions: Record<string, string> = { const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
@@ -320,6 +318,29 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
} }
}); });
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Shift+BracketLeft', ['__sub-delay-prev-line']),
createKeybinding('Shift+BracketRight', ['__sub-delay-next-line']),
],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(
result.bindings.map((binding) => ({
actionType: binding.actionType,
command: binding.actionType === 'mpv-command' ? binding.command : undefined,
})),
[
{ actionType: 'mpv-command', command: ['__sub-delay-prev-line'] },
{ actionType: 'mpv-command', command: ['__sub-delay-next-line'] },
],
);
});
test('compileSessionBindings omits disabled bindings', () => { test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({ const result = compileSessionBindings({
shortcuts: createShortcuts({ shortcuts: createShortcuts({
-8
View File
@@ -319,14 +319,6 @@ function resolveCommandBinding(
if (command.length !== 1) return null; if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' }; return { actionType: 'session-action', actionId: 'playNextSubtitle' };
} }
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) { if (command.length !== 1) {
return null; return null;
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined, cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined, cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
@@ -1,156 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
function createMpvClient(props: Record<string, unknown>) {
return {
connected: true,
requestProperty: async (name: string) => props[name],
};
}
test('shift subtitle delay to next cue using active external srt track', async () => {
const commands: Array<Array<string | number>> = [];
const osd: string[] = [];
let loadCount = 0;
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 2,
external: true,
'external-filename': '/tmp/subs.srt',
},
],
sid: 2,
'sub-start': 3.0,
}),
loadSubtitleSourceText: async () => {
loadCount += 1;
return `1
00:00:01,000 --> 00:00:02,000
line-1
2
00:00:03,000 --> 00:00:04,000
line-2
3
00:00:05,000 --> 00:00:06,000
line-3`;
},
sendMpvCommand: (command) => commands.push(command),
showMpvOsd: (text) => osd.push(text),
});
await handler('next');
await handler('next');
assert.equal(loadCount, 1);
assert.equal(commands.length, 2);
const delta = commands[0]?.[2];
assert.equal(commands[0]?.[0], 'add');
assert.equal(commands[0]?.[1], 'sub-delay');
assert.equal(typeof delta, 'number');
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
});
test('shift subtitle delay to previous cue using active external ass track', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 4,
external: true,
'external-filename': '/tmp/subs.ass',
},
],
sid: 4,
'sub-start': 2.0,
}),
loadSubtitleSourceText: async () => `[Events]
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
sendMpvCommand: (command) => commands.push(command),
showMpvOsd: () => {},
});
await handler('previous');
const delta = commands[0]?.[2];
assert.equal(typeof delta, 'number');
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
});
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
const shiftedDelays: number[] = [];
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 2,
external: true,
'external-filename': '/tmp/subs.srt',
},
],
sid: 2,
'sub-start': 3.0,
'sub-delay': 0.5,
}),
loadSubtitleSourceText: async () => `1
00:00:03,000 --> 00:00:04,000
line-1
2
00:00:05,000 --> 00:00:06,000
line-2`,
sendMpvCommand: () => {},
showMpvOsd: () => {},
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
});
await handler('next');
assert.deepEqual(shiftedDelays, [2.5]);
});
test('shift subtitle delay throws when no next cue exists', async () => {
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 1,
external: true,
'external-filename': '/tmp/subs.vtt',
},
],
sid: 1,
'sub-start': 5.0,
}),
loadSubtitleSourceText: async () => `WEBVTT
00:00:01.000 --> 00:00:02.000
line-1
00:00:03.000 --> 00:00:04.000
line-2
00:00:05.000 --> 00:00:06.000
line-3`,
sendMpvCommand: () => {},
showMpvOsd: () => {},
});
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
});
-210
View File
@@ -1,210 +0,0 @@
type SubtitleDelayShiftDirection = 'next' | 'previous';
type MpvClientLike = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
external?: unknown;
'external-filename'?: unknown;
};
type SubtitleCueCacheEntry = {
starts: number[];
};
type SubtitleDelayShiftDeps = {
getMpvClient: () => MpvClientLike | null;
loadSubtitleSourceText: (source: string) => Promise<string>;
sendMpvCommand: (command: Array<string | number>) => void;
showMpvOsd: (text: string) => void;
onSubtitleDelayShifted?: (delaySeconds: number) => void;
};
function asTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value.trim());
if (Number.isInteger(parsed)) return parsed;
}
return null;
}
function parseSrtOrVttStartTimes(content: string): number[] {
const starts: number[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
);
if (!match) continue;
const hours = Number(match[1] || 0);
const minutes = Number(match[2] || 0);
const seconds = Number(match[3] || 0);
const millis = Number(String(match[4]).padEnd(3, '0'));
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
}
return starts;
}
function parseAssStartTimes(content: string): number[] {
const starts: number[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
);
if (!match) continue;
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
if (secondsRaw === undefined) continue;
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
const hours = Number(hoursRaw);
const minutes = Number(minutesRaw);
const wholeSeconds = Number(wholeSecondsRaw);
const fraction = Number(`0.${fractionRaw}`);
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
}
return starts;
}
function normalizeCueStarts(starts: number[]): number[] {
const sorted = starts
.filter((value) => Number.isFinite(value) && value >= 0)
.sort((a, b) => a - b);
if (sorted.length === 0) return [];
const deduped: number[] = [sorted[0]!];
for (let i = 1; i < sorted.length; i += 1) {
const current = sorted[i]!;
const previous = deduped[deduped.length - 1]!;
if (Math.abs(current - previous) > 0.0005) {
deduped.push(current);
}
}
return deduped;
}
function parseCueStarts(content: string, source: string): number[] {
const normalizedSource = source.toLowerCase().split('?')[0] || '';
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
const parseAssLike = () => parseAssStartTimes(content);
let starts: number[] = [];
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
starts = parseAssLike();
if (starts.length === 0) {
starts = parseSrtLike();
}
} else {
starts = parseSrtLike();
if (starts.length === 0) {
starts = parseAssLike();
}
}
const normalized = normalizeCueStarts(starts);
if (normalized.length === 0) {
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
}
return normalized;
}
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
const sid = asTrackId(sidRaw);
if (sid === null) {
throw new Error('No active subtitle track selected.');
}
if (!Array.isArray(trackListRaw)) {
throw new Error('Could not inspect subtitle track list.');
}
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
if (!entry || typeof entry !== 'object') return false;
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && asTrackId(track.id) === sid;
});
if (!activeTrack) {
throw new Error('No active subtitle track found in mpv track list.');
}
if (activeTrack.external !== true) {
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
}
const source =
typeof activeTrack['external-filename'] === 'string'
? activeTrack['external-filename'].trim()
: '';
if (!source) {
throw new Error('Active subtitle track has no external subtitle source path.');
}
return source;
}
function findAdjacentCueStart(
starts: number[],
currentStart: number,
direction: SubtitleDelayShiftDirection,
): number {
const epsilon = 0.0005;
if (direction === 'next') {
const target = starts.find((value) => value > currentStart + epsilon);
if (target === undefined) {
throw new Error('No next subtitle cue found for active subtitle source.');
}
return target;
}
for (let index = starts.length - 1; index >= 0; index -= 1) {
const value = starts[index]!;
if (value < currentStart - epsilon) {
return value;
}
}
throw new Error('No previous subtitle cue found for active subtitle source.');
}
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
const cueCache = new Map<string, SubtitleCueCacheEntry>();
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
const client = deps.getMpvClient();
if (!client || !client.connected) {
throw new Error('MPV not connected.');
}
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('sub-start'),
client.requestProperty('sub-delay'),
]);
const currentStart =
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
if (currentStart === null) {
throw new Error('Current subtitle start time is unavailable.');
}
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
let cueStarts = cueCache.get(source)?.starts;
if (!cueStarts) {
const content = await deps.loadSubtitleSourceText(source);
cueStarts = parseCueStarts(content, source);
cueCache.set(source, { starts: cueStarts });
}
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
const delta = targetStart - currentStart;
deps.sendMpvCommand(['add', 'sub-delay', delta]);
const currentDelay =
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
try {
deps.onSubtitleDelayShifted?.(currentDelay + delta);
} catch {}
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
};
}
+1 -25
View File
@@ -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,
+2 -2
View File
@@ -226,10 +226,10 @@ export interface MpvCommandRuntimeServiceDepsParams {
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback']; showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand']; mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd']; resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected']; isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
@@ -424,10 +424,10 @@ export function createMpvCommandRuntimeServiceDeps(
openPlaylistBrowser: params.openPlaylistBrowser, openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd, showMpvOsd: params.showMpvOsd,
showRawMpvOsd: params.showRawMpvOsd,
showPlaybackFeedback: params.showPlaybackFeedback, showPlaybackFeedback: params.showPlaybackFeedback,
mpvReplaySubtitle: params.mpvReplaySubtitle, mpvReplaySubtitle: params.mpvReplaySubtitle,
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
mpvSendCommand: params.mpvSendCommand, mpvSendCommand: params.mpvSendCommand,
resolveProxyCommandOsd: params.resolveProxyCommandOsd, resolveProxyCommandOsd: params.resolveProxyCommandOsd,
isMpvConnected: params.isMpvConnected, isMpvConnected: params.isMpvConnected,
+2 -3
View File
@@ -17,10 +17,10 @@ export interface MpvCommandFromIpcRuntimeDeps {
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
showPlaybackFeedback?: (text: string) => void; showPlaybackFeedback?: (text: string) => void;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
playNextSubtitle: () => void; playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
getMpvClient: () => MpvPropertyClientLike | null; getMpvClient: () => MpvPropertyClientLike | null;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
@@ -42,11 +42,10 @@ export function handleMpvCommandFromIpcRuntime(
openPlaylistBrowser: deps.openPlaylistBrowser, openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
showRawMpvOsd: deps.showRawMpvOsd,
showPlaybackFeedback: deps.showPlaybackFeedback, showPlaybackFeedback: deps.showPlaybackFeedback,
mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvReplaySubtitle: deps.replayCurrentSubtitle,
mpvPlayNextSubtitle: deps.playNextSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle,
shiftSubDelayToAdjacentSubtitle: (direction) =>
deps.shiftSubDelayToAdjacentSubtitle(direction),
mpvSendCommand: deps.sendMpvCommand, mpvSendCommand: deps.sendMpvCommand,
resolveProxyCommandOsd: (nextCommand) => resolveProxyCommandOsd: (nextCommand) =>
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient), resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
@@ -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(),
-2
View File
@@ -980,8 +980,6 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
test('default keybindings dispatch through overlay keyboard handling', async () => { test('default keybindings dispatch through overlay keyboard handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();
const specialActionIds: Record<string, string> = { const specialActionIds: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
+6 -12
View File
@@ -96,18 +96,17 @@ function describeCommand(command: (string | number)[]): string {
if (command[1] < 0) return 'Jump to previous subtitle'; if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing'; return 'Reload current subtitle timing';
} }
if (first === 'sub-step' && typeof command[1] === 'number') {
if (command[1] > 0) return 'Shift subtitle delay to next cue';
if (command[1] < 0) return 'Shift subtitle delay to previous cue';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku'; if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':'); const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${ return `Cycle runtime option ${rawId || 'option'} ${
@@ -131,6 +130,7 @@ function sectionForCommand(command: (string | number)[]): string {
first === 'cycle' || first === 'cycle' ||
first === 'seek' || first === 'seek' ||
first === 'sub-seek' || first === 'sub-seek' ||
first === 'sub-step' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE || first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) { ) {
@@ -227,10 +227,6 @@ function describeSessionAction(
return 'Replay current subtitle'; return 'Replay current subtitle';
case 'playNextSubtitle': case 'playNextSubtitle':
return 'Play next subtitle'; return 'Play next subtitle';
case 'shiftSubDelayPrevLine':
return 'Shift subtitle delay to previous cue';
case 'shiftSubDelayNextLine':
return 'Shift subtitle delay to next cue';
case 'cycleRuntimeOption': case 'cycleRuntimeOption':
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${ return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next' payload?.direction === -1 ? 'previous' : 'next'
@@ -271,8 +267,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Modals and tools'; return 'Modals and tools';
case 'replayCurrentSubtitle': case 'replayCurrentSubtitle':
case 'playNextSubtitle': case 'playNextSubtitle':
case 'shiftSubDelayPrevLine':
case 'shiftSubDelayNextLine':
return 'Playback and navigation'; return 'Playback and navigation';
case 'cycleRuntimeOption': case 'cycleRuntimeOption':
return 'Runtime settings'; return 'Runtime settings';
+3 -7
View File
@@ -3,7 +3,6 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { createRendererState } from '../state.js'; import { createRendererState } from '../state.js';
import { import {
buildSessionHelpSections, buildSessionHelpSections,
@@ -17,13 +16,10 @@ test('session help describes sub-seek commands as subtitle-line navigation', ()
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle'); assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
}); });
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => { test('session help describes native subtitle-delay step commands separately from sub-seek', () => {
assert.equal(describeSessionHelpCommand(['sub-step', 1]), 'Shift subtitle delay to next cue');
assert.equal( assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]), describeSessionHelpCommand(['sub-step', -1]),
'Shift subtitle delay to next cue',
);
assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
'Shift subtitle delay to previous cue', 'Shift subtitle delay to previous cue',
); );
}); });
-2
View File
@@ -43,8 +43,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openPlaylistBrowser', 'openPlaylistBrowser',
'replayCurrentSubtitle', 'replayCurrentSubtitle',
'playNextSubtitle', 'playNextSubtitle',
'shiftSubDelayPrevLine',
'shiftSubDelayNextLine',
'cycleRuntimeOption', 'cycleRuntimeOption',
]; ];
-2
View File
@@ -25,8 +25,6 @@ export type SessionActionId =
| 'openPlaylistBrowser' | 'openPlaylistBrowser'
| 'replayCurrentSubtitle' | 'replayCurrentSubtitle'
| 'playNextSubtitle' | 'playNextSubtitle'
| 'shiftSubDelayPrevLine'
| 'shiftSubDelayNextLine'
| 'cycleRuntimeOption'; | 'cycleRuntimeOption';
export interface SessionKeySpec { export interface SessionKeySpec {