From 0a384a22c9d1ca129fb7bc0fe5a90a271fbea367 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 12 Jun 2026 00:03:06 -0700 Subject: [PATCH] Replace subtitle delay actions with native mpv keybindings (#120) --- changes/overlay-mpv-subtitle-keybindings.md | 4 + config.example.jsonc | 34 ++- docs-site/configuration.md | 187 ++++++++-------- docs-site/public/config.example.jsonc | 34 ++- docs-site/shortcuts.md | 95 ++++---- .../2026-03-15-renderer-performance-design.md | 2 +- plugin/subminer/session_bindings.lua | 54 +++-- release/release-notes.md | 80 ------- scripts/test-plugin-session-bindings.lua | 52 ++++- src/cli/args.test.ts | 11 +- src/cli/args.ts | 16 -- .../definitions/domain-registry.test.ts | 13 ++ src/config/definitions/shared.ts | 12 +- src/core/services/app-lifecycle.test.ts | 2 - src/core/services/cli-command.test.ts | 2 - src/core/services/cli-command.ts | 12 - src/core/services/index.ts | 1 - src/core/services/ipc-command.test.ts | 26 ++- src/core/services/ipc-command.ts | 49 ++-- src/core/services/session-actions.test.ts | 3 - src/core/services/session-actions.ts | 7 - src/core/services/session-bindings.test.ts | 25 ++- src/core/services/session-bindings.ts | 8 - src/core/services/startup-bootstrap.test.ts | 2 - .../services/subtitle-delay-shift.test.ts | 156 ------------- src/core/services/subtitle-delay-shift.ts | 210 ------------------ src/main.ts | 26 +-- src/main/dependencies.ts | 4 +- src/main/ipc-mpv-command.ts | 5 +- .../composers/ipc-runtime-composer.test.ts | 1 - .../runtime/first-run-setup-service.test.ts | 2 - src/main/runtime/first-run-setup-service.ts | 2 - .../ipc-bridge-actions-main-deps.test.ts | 1 - src/main/runtime/ipc-bridge-actions.test.ts | 1 - .../runtime/ipc-mpv-command-main-deps.test.ts | 8 +- src/main/runtime/ipc-mpv-command-main-deps.ts | 4 +- src/renderer/handlers/keyboard.test.ts | 2 - src/renderer/modals/session-help-sections.ts | 18 +- src/renderer/modals/session-help.test.ts | 10 +- src/shared/ipc/validators.ts | 2 - src/types/session-bindings.ts | 2 - 41 files changed, 395 insertions(+), 790 deletions(-) create mode 100644 changes/overlay-mpv-subtitle-keybindings.md delete mode 100644 release/release-notes.md delete mode 100644 src/core/services/subtitle-delay-shift.test.ts delete mode 100644 src/core/services/subtitle-delay-shift.ts diff --git a/changes/overlay-mpv-subtitle-keybindings.md b/changes/overlay-mpv-subtitle-keybindings.md new file mode 100644 index 00000000..b724b9fe --- /dev/null +++ b/changes/overlay-mpv-subtitle-keybindings.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 4bbeb33f..7a276ce8 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -290,15 +290,41 @@ ] // Command setting. }, { - "key": "Shift+BracketRight", // Key setting. + "key": "Ctrl+Shift+ArrowLeft", // Key setting. "command": [ - "__sub-delay-next-line" + "sub-step", + -1 ] // Command setting. }, { - "key": "Shift+BracketLeft", // Key setting. + "key": "Ctrl+Shift+ArrowRight", // Key setting. "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. }, { diff --git a/docs-site/configuration.md b/docs-site/configuration.md index cc36995a..99a9faad 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -571,26 +571,29 @@ See `config.example.jsonc` for detailed configuration options and more examples. **Default keybindings:** -| Key | Command | Description | -| -------------------- | ----------------------------- | --------------------------------------- | -| `Space` | `["cycle", "pause"]` | Toggle pause | -| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen | -| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | -| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | -| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | -| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | -| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | -| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | -| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | -| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | -| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | -| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | -| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue | -| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue | -| `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 | +| Key | Command | Description | +| ----------------------- | ----------------------------- | --------------------------------------- | +| `Space` | `["cycle", "pause"]` | Toggle pause | +| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen | +| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | +| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | +| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | +| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | +| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | +| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | +| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | +| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | +| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | +| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | +| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue | +| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue | +| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier | +| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms | +| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms | +| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | +| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | +| `KeyQ` | `["quit"]` | Quit mpv | +| `Ctrl+KeyW` | `["quit"]` | Quit mpv | **Custom keybindings example:** @@ -616,11 +619,11 @@ See `config.example.jsonc` for detailed configuration options and more examples. { "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:[: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:[:next|prev]` cycles a runtime option value. **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. @@ -655,26 +658,26 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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"`) | -| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | -| `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) | -| `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"`) | -| `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`) | -| `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"`) | -| `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"`) | -| `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"`) | -| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | -| `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. | +| Option | Values | Description | +| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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"`) | +| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | +| `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) | +| `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"`) | +| `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`) | +| `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"`) | +| `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"`) | +| `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"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | +| `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. | | `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. @@ -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. -| Option | Values | Description | -| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | -| `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) | -| `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.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`) | -| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | +| Option | Values | Description | +| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | +| `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) | +| `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.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`) | +| `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. | -| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | -| `fields.image` | string | Card field for images (default: `Picture`) | -| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | -| `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`) | -| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | -| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | -| `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.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.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.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | -| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | -| `media.audioPadding` | number (seconds) | 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.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.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.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.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.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.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`). | -| `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`) | -| `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`. | -| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | +| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | +| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `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`) | +| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `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.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.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.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | +| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | +| `media.audioPadding` | number (seconds) | 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.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.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.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.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.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.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`). | +| `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`) | +| `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`. | +| `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. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 4bbeb33f..7a276ce8 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -290,15 +290,41 @@ ] // Command setting. }, { - "key": "Shift+BracketRight", // Key setting. + "key": "Ctrl+Shift+ArrowLeft", // Key setting. "command": [ - "__sub-delay-next-line" + "sub-step", + -1 ] // Command setting. }, { - "key": "Shift+BracketLeft", // Key setting. + "key": "Ctrl+Shift+ArrowRight", // Key setting. "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. }, { diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 179520e7..10e85d01 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -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. -| Shortcut | Action | -| -------------------- | --------------------------------------------------- | -| `Space` | Toggle mpv pause | -| `F` | Toggle fullscreen | +| Shortcut | Action | +| -------------------- | ---------------------------------------------------------- | +| `Space` | Toggle mpv pause | +| `F` | Toggle fullscreen | | `V` | Cycle primary subtitle bar mode (hidden → visible → hover) | -| `J` | Cycle primary subtitle track | -| `Shift+J` | Cycle secondary subtitle track | -| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | -| `ArrowRight` | Seek forward 5 seconds | -| `ArrowLeft` | Seek backward 5 seconds | -| `ArrowUp` | Seek forward 60 seconds | -| `ArrowDown` | Seek backward 60 seconds | -| `Shift+H` | Jump to previous subtitle | -| `Shift+L` | Jump to next subtitle | -| `Shift+[` | Shift subtitle delay to previous subtitle cue | -| `Shift+]` | Shift subtitle delay to next subtitle cue | -| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | -| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | -| `Q` | Quit mpv | -| `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 | +| `J` | Cycle primary subtitle track | +| `Shift+J` | Cycle secondary subtitle track | +| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | +| `ArrowRight` | Seek forward 5 seconds | +| `ArrowLeft` | Seek backward 5 seconds | +| `ArrowUp` | Seek forward 60 seconds | +| `ArrowDown` | Seek backward 60 seconds | +| `Shift+H` | Jump to previous subtitle | +| `Shift+L` | Jump to next subtitle | +| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue | +| `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue | +| `z` | Shift subtitles 100 ms earlier | +| `Shift+Z` | Delay subtitles by 100 ms | +| `x` | Delay subtitles by 100 ms | +| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | +| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | +| `Q` | Quit mpv | +| `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. @@ -75,19 +78,19 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle ## Subtitle & Feature Shortcuts -| Shortcut | Action | Config key | -| ------------------ | -------------------------------------------------------- | ----------------------------------------------- | -| `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+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | -| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | -| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | -| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | -| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | -| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | -| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | -| `` ` `` | Toggle stats overlay | `stats.toggleKey` | -| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ------------------------------------------ | +| `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+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | +| `` ` `` | Toggle stats overlay | `stats.toggleKey` | +| `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`. @@ -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. -| Chord | Action | -| ----- | -------------------------------------- | -| `y-y` | Open SubMiner menu (OSD) | -| `y-s` | Start overlay | -| `y-S` | Stop overlay | -| `y-t` | Toggle visible overlay | +| Chord | Action | +| ----- | ---------------------------------------------------------- | +| `y-y` | Open SubMiner menu (OSD) | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | | `v` | Cycle primary subtitle bar mode (hidden → visible → hover) | -| `y-o` | Open Yomitan settings | -| `y-r` | Restart overlay | -| `y-c` | Check overlay status | -| `y-h` | Open session help | +| `y-o` | Open Yomitan settings | +| `y-r` | Restart overlay | +| `y-c` | Check overlay status | +| `y-h` | Open session help | The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead. diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md index 42dfa07a..8b01ff9a 100644 --- a/docs/architecture/2026-03-15-renderer-performance-design.md +++ b/docs/architecture/2026-03-15-renderer-performance-design.md @@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o #### 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:** ```typescript diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 4e9d1c2d..b0381f7e 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -270,10 +270,6 @@ function M.create(ctx) return { "--replay-current-subtitle" } elseif action_id == "playNextSubtitle" then 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 local runtime_option_id = payload and payload.runtimeOptionId or nil 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) 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 artifact_path = environment.resolve_session_bindings_artifact_path() local raw = read_file(artifact_path) @@ -385,26 +391,34 @@ function M.create(ctx) local generation = state.session_binding_generation for index, binding in ipairs(artifact.bindings) do - 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 + if not is_supported_binding(binding) then subminer_log( "warn", "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 diff --git a/release/release-notes.md b/release/release-notes.md deleted file mode 100644 index 0568ea24..00000000 --- a/release/release-notes.md +++ /dev/null @@ -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. - -
-Internal changes - -### 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. - -
- -## 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`. diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 6272ce95..952adb17 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -165,6 +165,46 @@ local ctx = { actionType = "mpv-command", 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 = { code = "BracketRight", @@ -323,6 +363,11 @@ local expected_mpv_bindings = { { keys = "DOWN", command = { "seek", -60 } }, { keys = "H", 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 = "Ctrl+w", command = { "quit" } }, { keys = "MBTN_BACK", command = { "sub-seek", -1 } }, @@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do end 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+p", flag = "--open-playlist-browser" }, { 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) 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") assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form") diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index a71a1f61..6c1be2c2 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -101,8 +101,6 @@ test('parseArgs captures session action forwarding flags', () => { '--toggle-primary-subtitle-bar', '--replay-current-subtitle', '--play-next-subtitle', - '--shift-sub-delay-prev-line', - '--shift-sub-delay-next-line', '--cycle-runtime-option', 'anki.autoUpdateNewCards:prev', '--session-action', @@ -120,8 +118,6 @@ test('parseArgs captures session action forwarding flags', () => { assert.equal(args.togglePrimarySubtitleBar, true); assert.equal(args.replayCurrentSubtitle, 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.cycleRuntimeOptionDirection, -1); assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' }); @@ -131,6 +127,13 @@ test('parseArgs captures session action forwarding flags', () => { 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', () => { const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']); diff --git a/src/cli/args.ts b/src/cli/args.ts index 2f078d72..dfdf9228 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -41,8 +41,6 @@ export interface CliArgs { openPlaylistBrowser: boolean; replayCurrentSubtitle: boolean; playNextSubtitle: boolean; - shiftSubDelayPrevLine: boolean; - shiftSubDelayNextLine: boolean; playbackFeedback?: string; cycleRuntimeOptionId?: string; cycleRuntimeOptionDirection?: 1 | -1; @@ -149,8 +147,6 @@ export function parseArgs(argv: string[]): CliArgs { openPlaylistBrowser: false, replayCurrentSubtitle: false, playNextSubtitle: false, - shiftSubDelayPrevLine: false, - shiftSubDelayNextLine: false, playbackFeedback: undefined, anilistStatus: 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 === '--replay-current-subtitle') args.replayCurrentSubtitle = 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=')) { const value = arg.slice('--playback-feedback='.length).trim(); if (value) args.playbackFeedback = value; @@ -562,8 +556,6 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.openPlaylistBrowser || args.replayCurrentSubtitle || args.playNextSubtitle || - args.shiftSubDelayPrevLine || - args.shiftSubDelayNextLine || args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || @@ -638,8 +630,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.openPlaylistBrowser && !args.replayCurrentSubtitle && !args.playNextSubtitle && - !args.shiftSubDelayPrevLine && - !args.shiftSubDelayNextLine && args.playbackFeedback === undefined && args.cycleRuntimeOptionId === undefined && args.sessionAction === undefined && @@ -705,8 +695,6 @@ export function shouldStartApp(args: CliArgs): boolean { args.openPlaylistBrowser || args.replayCurrentSubtitle || args.playNextSubtitle || - args.shiftSubDelayPrevLine || - args.shiftSubDelayNextLine || args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || @@ -766,8 +754,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { !args.openPlaylistBrowser && !args.replayCurrentSubtitle && !args.playNextSubtitle && - !args.shiftSubDelayPrevLine && - !args.shiftSubDelayNextLine && args.playbackFeedback === undefined && args.cycleRuntimeOptionId === undefined && args.sessionAction === undefined && @@ -832,8 +818,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.openPlaylistBrowser || args.replayCurrentSubtitle || args.playNextSubtitle || - args.shiftSubDelayPrevLine || - args.shiftSubDelayNextLine || args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index b1579de2..b1eefc65 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -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+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); +}); diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 71f939a0..76aadf38 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -55,8 +55,6 @@ export const SPECIAL_COMMANDS = { RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', REPLAY_SUBTITLE: '__replay-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', PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', } as const; @@ -72,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable = [ { key: 'ArrowDown', command: ['seek', -60] }, { key: 'Shift+KeyH', 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: 'Shift+BracketLeft', - command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], - }, + { key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] }, + { key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] }, + { key: 'KeyZ', command: ['add', 'sub-delay', -0.1] }, + { 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+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index a833baf5..eef31db4 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -43,8 +43,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { openPlaylistBrowser: false, replayCurrentSubtitle: false, playNextSubtitle: false, - shiftSubDelayPrevLine: false, - shiftSubDelayNextLine: false, cycleRuntimeOptionId: undefined, cycleRuntimeOptionDirection: undefined, anilistStatus: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 850eca0a..9f04ebbd 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -49,8 +49,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { togglePrimarySubtitleBar: false, replayCurrentSubtitle: false, playNextSubtitle: false, - shiftSubDelayPrevLine: false, - shiftSubDelayNextLine: false, playbackFeedback: undefined, cycleRuntimeOptionId: undefined, cycleRuntimeOptionDirection: undefined, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 86902ba5..675365ed 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -537,18 +537,6 @@ export function handleCliCommand( 'playNextSubtitle', '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) { const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd; showFeedback(args.playbackFeedback); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index cf3400ff..8baa7c24 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -10,7 +10,6 @@ export { unregisterOverlayShortcutsRuntime, } from './overlay-shortcut'; export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler'; -export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift'; export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command'; export { copyCurrentSubtitle, diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index def77565..2d29884a 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -15,8 +15,6 @@ function createOptions(overrides: Partial { calls.push('next'); }, - shiftSubDelayToAdjacentSubtitle: async (direction) => { - calls.push(`shift:${direction}`); - }, mpvSendCommand: (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(); handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]); - assert.deepEqual(osd, []); - assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']); + assert.deepEqual(osd, ['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(); handleMpvCommandFromIpc(['__sub-delay-next-line'], options); - assert.deepEqual(calls, ['shift:next']); - assert.deepEqual(sentCommands, []); + assert.deepEqual(calls, []); + assert.deepEqual(sentCommands, [['__sub-delay-next-line']]); assert.deepEqual(osd, []); }); diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 9099bf21..5e6138d8 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -13,8 +13,6 @@ export interface HandleMpvCommandFromIpcOptions { RUNTIME_OPTION_CYCLE_PREFIX: string; REPLAY_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; PLAYLIST_BROWSER_OPEN: string; }; @@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions { openPlaylistBrowser: () => void | Promise; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; + showRawMpvOsd?: (text: string) => void; showPlaybackFeedback?: (text: string) => void; mpvReplaySubtitle: () => void; mpvPlayNextSubtitle: () => void; - shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; mpvSendCommand: (command: (string | number)[]) => void; resolveProxyCommandOsd?: (command: (string | number)[]) => Promise; isMpvConnected: () => boolean; @@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([ '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] : ''; + if (operation === 'sub-step') { + return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true }; + } + const property = typeof command[1] === 'string' ? command[1] : ''; if (!MPV_PROPERTY_COMMANDS.has(operation)) return null; if (property === 'sub-pos') { - return 'Subtitle position: ${sub-pos}'; + return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false }; } if (property === 'sid') { - return 'Subtitle track: ${sid}'; + return { template: 'Subtitle track: ${sid}', rawMpvOsd: false }; } if (property === 'secondary-sid') { - return 'Secondary subtitle track: ${secondary-sid}'; + return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false }; } if (property === 'sub-delay') { - return 'Subtitle delay: ${sub-delay}'; + return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true }; } return null; } @@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd( command: (string | number)[], options: HandleMpvCommandFromIpcOptions, ): void { - const template = resolveProxyCommandOsdTemplate(command); - if (!template) return; - const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd; + const feedback = resolveProxyCommandOsdTemplate(command); + if (!feedback) return; + const showFeedback = feedback.rawMpvOsd + ? (options.showRawMpvOsd ?? options.showMpvOsd) + : (options.showPlaybackFeedback ?? options.showMpvOsd); const emit = async () => { try { const resolved = await options.resolveProxyCommandOsd?.(command); - showFeedback(resolved || template); + showFeedback(resolved || feedback.template); } catch { - showFeedback(template); + showFeedback(feedback.template); } }; @@ -118,20 +127,6 @@ export function handleMpvCommandFromIpc( 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 (!options.hasRuntimeOptionsManager()) return; const [, idToken, directionToken] = first.split(':'); diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index cbd6bd03..3e9daa4f 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -47,9 +47,6 @@ function createDeps(overrides: Partial = {}) { }, replayCurrentSubtitle: () => calls.push('replay'), playNextSubtitle: () => calls.push('play-next'), - shiftSubDelayToAdjacentSubtitle: async (direction) => { - calls.push(`shift:${direction}`); - }, cycleRuntimeOption: () => ({ ok: true }), playNextPlaylistItem: () => calls.push('playlist-next'), showMpvOsd: (text) => calls.push(`osd:${text}`), diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 8cc31e36..d32bce81 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps { openPlaylistBrowser: () => boolean | void | Promise; replayCurrentSubtitle: () => void; playNextSubtitle: () => void; - shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; playNextPlaylistItem: () => void; showMpvOsd: (text: string) => void; @@ -124,12 +123,6 @@ export async function dispatchSessionAction( case 'playNextSubtitle': deps.playNextSubtitle(); return; - case 'shiftSubDelayPrevLine': - await deps.shiftSubDelayToAdjacentSubtitle('previous'); - return; - case 'shiftSubDelayNextLine': - await deps.shiftSubDelayToAdjacentSubtitle('next'); - return; case 'cycleRuntimeOption': { const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined; if (!runtimeOptionId) { diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index e616b047..7dacbbcc 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -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', () => { const expectedSpecialActions: Record = { - [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.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', [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', () => { const result = compileSessionBindings({ shortcuts: createShortcuts({ diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index c91fc1bd..b04a847a 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -319,14 +319,6 @@ function resolveCommandBinding( if (command.length !== 1) return null; 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 (command.length !== 1) { return null; diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 6de356fd..dff3cd8c 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -43,8 +43,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { openPlaylistBrowser: false, replayCurrentSubtitle: false, playNextSubtitle: false, - shiftSubDelayPrevLine: false, - shiftSubDelayNextLine: false, cycleRuntimeOptionId: undefined, cycleRuntimeOptionDirection: undefined, anilistStatus: false, diff --git a/src/core/services/subtitle-delay-shift.test.ts b/src/core/services/subtitle-delay-shift.test.ts deleted file mode 100644 index 60b44586..00000000 --- a/src/core/services/subtitle-delay-shift.test.ts +++ /dev/null @@ -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) { - 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> = []; - 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> = []; - 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/); -}); diff --git a/src/core/services/subtitle-delay-shift.ts b/src/core/services/subtitle-delay-shift.ts deleted file mode 100644 index 9a49f8c7..00000000 --- a/src/core/services/subtitle-delay-shift.ts +++ /dev/null @@ -1,210 +0,0 @@ -type SubtitleDelayShiftDirection = 'next' | 'previous'; - -type MpvClientLike = { - connected: boolean; - requestProperty: (name: string) => Promise; -}; - -type MpvSubtitleTrackLike = { - type?: unknown; - id?: unknown; - external?: unknown; - 'external-filename'?: unknown; -}; - -type SubtitleCueCacheEntry = { - starts: number[]; -}; - -type SubtitleDelayShiftDeps = { - getMpvClient: () => MpvClientLike | null; - loadSubtitleSourceText: (source: string) => Promise; - sendMpvCommand: (command: Array) => 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(); - - return async (direction: SubtitleDelayShiftDirection): Promise => { - 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}'); - }; -} diff --git a/src/main.ts b/src/main.ts index 505e14f8..81d6b257 100644 --- a/src/main.ts +++ b/src/main.ts @@ -348,7 +348,6 @@ import { copyCurrentSubtitle as copyCurrentSubtitleCore, createConfigHotReloadRuntime, createDiscordPresenceService, - createShiftSubtitleDelayToAdjacentCueHandler, createFieldGroupingOverlayRuntime, createOverlayContentMeasurementStore, 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 { await dispatchSessionActionCore(request, { toggleStatsOverlay: () => @@ -6905,8 +6884,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro openPlaylistBrowser: () => openPlaylistBrowser(), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), - shiftSubDelayToAdjacentSubtitle: (direction) => - shiftSubtitleDelayToAdjacentCueHandler(direction), cycleRuntimeOption: (id, direction) => { if (!appState.runtimeOptionsManager) { return { ok: false, error: 'Runtime options manager unavailable' }; @@ -6944,11 +6921,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ ); }, showMpvOsd: (text: string) => showConfiguredStatusNotification(text), + showRawMpvOsd: (text: string) => showMpvOsd(text), showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), - shiftSubDelayToAdjacentSubtitle: (direction) => - shiftSubtitleDelayToAdjacentCueHandler(direction), sendMpvCommand: (rawCommand: (string | number)[]) => sendMpvCommandRuntime(appState.mpvClient, rawCommand), getMpvClient: () => appState.mpvClient, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index edc2f125..50dbd232 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -226,10 +226,10 @@ export interface MpvCommandRuntimeServiceDepsParams { openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; + showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd']; showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; - shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle']; mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand']; resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd']; isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected']; @@ -424,10 +424,10 @@ export function createMpvCommandRuntimeServiceDeps( openPlaylistBrowser: params.openPlaylistBrowser, runtimeOptionsCycle: params.runtimeOptionsCycle, showMpvOsd: params.showMpvOsd, + showRawMpvOsd: params.showRawMpvOsd, showPlaybackFeedback: params.showPlaybackFeedback, mpvReplaySubtitle: params.mpvReplaySubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, - shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle, mpvSendCommand: params.mpvSendCommand, resolveProxyCommandOsd: params.resolveProxyCommandOsd, isMpvConnected: params.isMpvConnected, diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts index 6a41f835..adc4015e 100644 --- a/src/main/ipc-mpv-command.ts +++ b/src/main/ipc-mpv-command.ts @@ -17,10 +17,10 @@ export interface MpvCommandFromIpcRuntimeDeps { openPlaylistBrowser: () => void | Promise; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; + showRawMpvOsd?: (text: string) => void; showPlaybackFeedback?: (text: string) => void; replayCurrentSubtitle: () => void; playNextSubtitle: () => void; - shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; sendMpvCommand: (command: (string | number)[]) => void; getMpvClient: () => MpvPropertyClientLike | null; isMpvConnected: () => boolean; @@ -42,11 +42,10 @@ export function handleMpvCommandFromIpcRuntime( openPlaylistBrowser: deps.openPlaylistBrowser, runtimeOptionsCycle: deps.cycleRuntimeOption, showMpvOsd: deps.showMpvOsd, + showRawMpvOsd: deps.showRawMpvOsd, showPlaybackFeedback: deps.showPlaybackFeedback, mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle, - shiftSubDelayToAdjacentSubtitle: (direction) => - deps.shiftSubDelayToAdjacentSubtitle(direction), mpvSendCommand: deps.sendMpvCommand, resolveProxyCommandOsd: (nextCommand) => resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient), diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 7f977613..85e32b2a 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -17,7 +17,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, playNextSubtitle: () => {}, - shiftSubDelayToAdjacentSubtitle: async () => {}, sendMpvCommand: () => {}, getMpvClient: () => null, isMpvConnected: () => false, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index b71b03cc..9970e2e7 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -58,8 +58,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { openPlaylistBrowser: false, replayCurrentSubtitle: false, playNextSubtitle: false, - shiftSubDelayPrevLine: false, - shiftSubDelayNextLine: false, cycleRuntimeOptionId: undefined, cycleRuntimeOptionDirection: undefined, anilistStatus: false, diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index b6a3276a..f9e44a6f 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -101,8 +101,6 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.openPlaylistBrowser || args.replayCurrentSubtitle || args.playNextSubtitle || - args.shiftSubDelayPrevLine || - args.shiftSubDelayNextLine || args.cycleRuntimeOptionId !== undefined || args.anilistStatus || args.anilistLogout || diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts index 771d5bcd..fcca645e 100644 --- a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts +++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts @@ -20,7 +20,6 @@ test('ipc bridge action main deps builders map callbacks', async () => { showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, playNextSubtitle: () => {}, - shiftSubDelayToAdjacentSubtitle: async () => {}, sendMpvCommand: () => {}, getMpvClient: () => null, isMpvConnected: () => true, diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts index 9b0e938a..0daafff6 100644 --- a/src/main/runtime/ipc-bridge-actions.test.ts +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -17,7 +17,6 @@ test('handle mpv command handler forwards command and built deps', () => { showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, playNextSubtitle: () => {}, - shiftSubDelayToAdjacentSubtitle: async () => {}, sendMpvCommand: () => {}, getMpvClient: () => null, isMpvConnected: () => true, diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts index a36ee266..cc9a23f9 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -16,12 +16,10 @@ test('ipc mpv command main deps builder maps callbacks', () => { }, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: (text) => calls.push(`osd:${text}`), + showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`), showPlaybackFeedback: (text) => calls.push(`feedback:${text}`), replayCurrentSubtitle: () => calls.push('replay'), playNextSubtitle: () => calls.push('next'), - shiftSubDelayToAdjacentSubtitle: async (direction) => { - calls.push(`shift:${direction}`); - }, sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`), getMpvClient: () => ({ connected: true, requestProperty: async () => null }), isMpvConnected: () => true, @@ -35,10 +33,10 @@ test('ipc mpv command main deps builder maps callbacks', () => { void deps.openPlaylistBrowser(); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); deps.showMpvOsd('hello'); + deps.showRawMpvOsd?.('delay'); deps.showPlaybackFeedback?.('primary'); deps.replayCurrentSubtitle(); deps.playNextSubtitle(); - void deps.shiftSubDelayToAdjacentSubtitle('next'); deps.sendMpvCommand(['show-text', 'ok']); assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function'); assert.equal(deps.isMpvConnected(), true); @@ -50,10 +48,10 @@ test('ipc mpv command main deps builder maps callbacks', () => { 'youtube-picker', 'playlist-browser', 'osd:hello', + 'raw-osd:delay', 'feedback:primary', 'replay', 'next', - 'shift:next', 'cmd:show-text:ok', ]); }); diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts index da567f99..e3e5be86 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -5,6 +5,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( ) { return (): MpvCommandFromIpcRuntimeDeps => { const showPlaybackFeedback = deps.showPlaybackFeedback; + const showRawMpvOsd = deps.showRawMpvOsd; return { triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), @@ -13,13 +14,12 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( openPlaylistBrowser: () => deps.openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), showMpvOsd: (text: string) => deps.showMpvOsd(text), + ...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}), ...(showPlaybackFeedback ? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) } : {}), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), playNextSubtitle: () => deps.playNextSubtitle(), - shiftSubDelayToAdjacentSubtitle: (direction) => - deps.shiftSubDelayToAdjacentSubtitle(direction), sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), getMpvClient: () => deps.getMpvClient(), isMpvConnected: () => deps.isMpvConnected(), diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 8a854a01..25b5011b 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -980,8 +980,6 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => { test('default keybindings dispatch through overlay keyboard handling', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); const specialActionIds: Record = { - [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.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 9d8728bb..5c34a848 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -96,18 +96,17 @@ function describeCommand(command: (string | number)[]): string { if (command[1] < 0) return 'Jump to previous subtitle'; 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.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku'; 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.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)) { const [, rawId, rawDirection] = first.split(':'); return `Cycle runtime option ${rawId || 'option'} ${ @@ -131,6 +130,7 @@ function sectionForCommand(command: (string | number)[]): string { first === 'cycle' || first === 'seek' || first === 'sub-seek' || + first === 'sub-step' || first === SPECIAL_COMMANDS.REPLAY_SUBTITLE || first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE ) { @@ -227,10 +227,6 @@ function describeSessionAction( return 'Replay current subtitle'; case 'playNextSubtitle': return 'Play next subtitle'; - case 'shiftSubDelayPrevLine': - return 'Shift subtitle delay to previous cue'; - case 'shiftSubDelayNextLine': - return 'Shift subtitle delay to next cue'; case 'cycleRuntimeOption': return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${ payload?.direction === -1 ? 'previous' : 'next' @@ -271,8 +267,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string { return 'Modals and tools'; case 'replayCurrentSubtitle': case 'playNextSubtitle': - case 'shiftSubDelayPrevLine': - case 'shiftSubDelayNextLine': return 'Playback and navigation'; case 'cycleRuntimeOption': return 'Runtime settings'; diff --git a/src/renderer/modals/session-help.test.ts b/src/renderer/modals/session-help.test.ts index ebad232f..16f50ee3 100644 --- a/src/renderer/modals/session-help.test.ts +++ b/src/renderer/modals/session-help.test.ts @@ -3,7 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; -import { SPECIAL_COMMANDS } from '../../config/definitions/shared'; import { createRendererState } from '../state.js'; import { 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'); }); -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( - describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]), - 'Shift subtitle delay to next cue', - ); - assert.equal( - describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]), + describeSessionHelpCommand(['sub-step', -1]), 'Shift subtitle delay to previous cue', ); }); diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 968c3a93..b741082a 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -43,8 +43,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [ 'openPlaylistBrowser', 'replayCurrentSubtitle', 'playNextSubtitle', - 'shiftSubDelayPrevLine', - 'shiftSubDelayNextLine', 'cycleRuntimeOption', ]; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts index 1a10ddcc..2c0a10e1 100644 --- a/src/types/session-bindings.ts +++ b/src/types/session-bindings.ts @@ -25,8 +25,6 @@ export type SessionActionId = | 'openPlaylistBrowser' | 'replayCurrentSubtitle' | 'playNextSubtitle' - | 'shiftSubDelayPrevLine' - | 'shiftSubDelayNextLine' | 'cycleRuntimeOption'; export interface SessionKeySpec {