From ca673735ba37cb2ecc6a914dd6b86f8dd8a2b353 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 21 Sep 2026 21:32:41 -0700 Subject: [PATCH] feat(overlay): add optional subtitle selection modal - Add primary and secondary mpv track selection from the overlay - Support configurable sequence shortcuts with conflict detection and hot reload - Document settings, shortcuts, and generated config defaults --- changes/subtitle-selection-docs.md | 4 + changes/subtitle-selection.md | 5 + config.example.jsonc | 10 + docs-site/configuration.md | 8 + docs-site/public/config.example.jsonc | 10 + docs-site/shortcuts.md | 3 + docs/architecture/README.md | 2 + plugin/subminer/session_bindings.lua | 129 ++++++++++++- scripts/test-plugin-session-bindings.lua | 51 +++++- src/config/definitions.ts | 1 + src/config/definitions/defaults-core.ts | 1 + src/config/definitions/options-core.ts | 7 + src/config/definitions/options-subtitle.ts | 7 + src/config/definitions/template-sections.ts | 6 + src/config/hot-reload.ts | 8 +- src/config/resolve/core-domains.ts | 20 ++ src/config/resolve/subtitle-selection.test.ts | 63 +++++++ src/config/settings/registry.ts | 4 + src/core/services/ipc.ts | 4 +- .../services/overlay-shortcut-handler.test.ts | 1 + src/core/services/overlay-shortcut.test.ts | 1 + src/core/services/session-actions.test.ts | 1 + src/core/services/session-actions.ts | 4 + src/core/services/session-bindings.test.ts | 56 ++++++ src/core/services/session-bindings.ts | 44 ++++- src/core/utils/shortcut-config.ts | 5 + src/main.ts | 78 ++++++-- .../runtime/config-hot-reload-handlers.ts | 5 +- .../global-shortcuts-runtime-handlers.test.ts | 1 + src/main/runtime/global-shortcuts.test.ts | 1 + .../runtime/session-bindings-runtime.test.ts | 66 +++++++ src/main/runtime/session-bindings-runtime.ts | 90 ++++++++- src/main/runtime/subtitle-selection.test.ts | 101 ++++++++++ src/main/runtime/subtitle-selection.ts | 117 ++++++++++++ src/preload.ts | 14 ++ .../controller-interaction-blocking.ts | 2 + src/renderer/handlers/keyboard.test.ts | 84 +++++++++ src/renderer/handlers/keyboard.ts | 37 +++- src/renderer/index.html | 37 ++++ src/renderer/modals/session-help-sections.ts | 3 + src/renderer/modals/subtitle-selection.ts | 172 ++++++++++++++++++ src/renderer/overlay-mouse-ignore.ts | 1 + src/renderer/renderer.ts | 22 ++- src/renderer/state.ts | 2 + src/renderer/style.css | 10 + src/shared/ipc/contracts.ts | 5 + src/shared/ipc/validators.ts | 1 + src/shared/mpv-input-bindings.test.ts | 10 + src/shared/mpv-input-bindings.ts | 17 +- src/shared/session-key-sequences.ts | 71 ++++++++ src/shared/subtitle-selection.ts | 33 ++++ src/types/config.ts | 3 + src/types/runtime.ts | 10 + src/types/session-bindings.ts | 1 + 54 files changed, 1397 insertions(+), 52 deletions(-) create mode 100644 changes/subtitle-selection-docs.md create mode 100644 changes/subtitle-selection.md create mode 100644 src/config/resolve/subtitle-selection.test.ts create mode 100644 src/main/runtime/subtitle-selection.test.ts create mode 100644 src/main/runtime/subtitle-selection.ts create mode 100644 src/renderer/modals/subtitle-selection.ts create mode 100644 src/shared/session-key-sequences.ts create mode 100644 src/shared/subtitle-selection.ts diff --git a/changes/subtitle-selection-docs.md b/changes/subtitle-selection-docs.md new file mode 100644 index 00000000..84069255 --- /dev/null +++ b/changes/subtitle-selection-docs.md @@ -0,0 +1,4 @@ +type: docs +area: config + +- Documented the subtitle selector setting, shortcut override, and primary/secondary track controls. diff --git a/changes/subtitle-selection.md b/changes/subtitle-selection.md new file mode 100644 index 00000000..9e67a2e6 --- /dev/null +++ b/changes/subtitle-selection.md @@ -0,0 +1,5 @@ +type: added +area: overlay + +- Added an optional Catppuccin subtitle selection modal for primary and secondary mpv tracks. Enable it in Settings under Behavior, then press g followed by s. Disabling it restores mpv's subtitle selection binding. +- Single-key actions take priority over configured sequence prefixes. Conflicting sequences are disabled with a warning, and the existing y commands stay reserved. diff --git a/config.example.jsonc b/config.example.jsonc index 8e9a3cd6..22640179 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -6,6 +6,15 @@ */ { + // ========================================== + // Subtitle Selection + // Select primary and secondary mpv subtitle tracks from the overlay. + // Hot-reload: enabling or disabling updates the session shortcut immediately. + // ========================================== + "subtitleSelection": { + "enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false + }, // Select primary and secondary mpv subtitle tracks from the overlay. + // ========================================== // Japanese Subtitle Generation // Generate timed Japanese subtitles from local audio using whisper.cpp. @@ -223,6 +232,7 @@ "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs). + "openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind. "openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 89d2ab40..847058af 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1117,6 +1117,14 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open Open demo in a new tab +## Subtitle Selection + +Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals. + +Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available. + +`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults. + ## External integrations ### Jimaku diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 8e9a3cd6..22640179 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -6,6 +6,15 @@ */ { + // ========================================== + // Subtitle Selection + // Select primary and secondary mpv subtitle tracks from the overlay. + // Hot-reload: enabling or disabling updates the session shortcut immediately. + // ========================================== + "subtitleSelection": { + "enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false + }, // Select primary and secondary mpv subtitle tracks from the overlay. + // ========================================== // Japanese Subtitle Generation // Generate timed Japanese subtitles from local audio using whisper.cpp. @@ -223,6 +232,7 @@ "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs). + "openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind. "openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 4dd5b6b0..d1f44f7e 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -88,6 +88,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` | | `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) | @@ -98,6 +99,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle 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`. +Enable the subtitle selector in **Settings → Behavior → Subtitle Selection**. Its shortcut overrides mpv subtitle selection only while enabled. In the focused overlay, press the second key within one second. Single-key bindings take priority: if `g` already has an action in SubMiner or mpv, `g-s` is disabled with a conflict warning, and `g` still runs immediately. Remap the sequence or remove the conflicting single-key binding. The existing `y` prefix is reserved for its built-in commands. mpv bindings are checked on connection, configuration changes, and overlay focus; refresh the overlay after changing another script's bindings. See [subtitle selection](/configuration#subtitle-selection). + The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1f65d433..58eb074b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -35,6 +35,8 @@ Update checks and startup launcher migration share a serialized update-state sto - `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative. - Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments. - `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps. +- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry. +- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes. - `src/renderer/` owns overlay rendering and input behavior. - `src/config/` owns config definitions, defaults, loading, and resolution. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 8c40b8b3..b0f81a5a 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -91,6 +91,10 @@ function M.create(ctx) end local function key_code_to_mpv_name(code) + local first, second = code:match("^Key([A-Z])%-Key([A-Z])$") + if first and second then + return string.lower(first) .. "-" .. string.lower(second) + end if KEY_NAME_MAP[code] then return KEY_NAME_MAP[code] end @@ -187,6 +191,110 @@ function M.create(ctx) return bindings end + -- Match letter strokes, including mpv's uppercase spelling for Shift. + local function letter_key_signature(value) + if type(value) ~= "string" then + return nil + end + local modifiers = {} + while true do + local modifier, rest = value:match("^([%a]+)%+(.+)$") + if not modifier then + break + end + modifier = string.lower(modifier) + if not MODIFIER_MAP[modifier] then + return nil + end + modifiers[modifier] = true + value = rest + end + if not value:match("^[a-zA-Z]$") then + return nil + end + if value:match("^[A-Z]$") then + modifiers.shift = true + end + local parts = {} + for _, modifier in ipairs({ "ctrl", "alt", "shift", "meta" }) do + if modifiers[modifier] then + parts[#parts + 1] = modifier + end + end + parts[#parts + 1] = string.lower(value) + return table.concat(parts, "+") + end + + local function external_single_keys() + local keys = {} + local native = mp.get_property_native and mp.get_property_native("input-bindings") or {} + for _, entry in ipairs(native or {}) do + local signature = letter_key_signature(entry.key) + if + signature + and type(entry.cmd) == "string" + and type(entry.priority) == "number" + and entry.priority >= 0 + then + local owned = entry.owner == "subminer" + or ( + entry.owner == nil + and ( + entry.cmd:match("script%-binding%s+['\"]?subminer/") + or entry.cmd:match("script%-message%s+['\"]?subminer%-") + ) + ) + local previous = keys[signature] + if + not previous + or entry.priority > previous.priority + or (entry.priority == previous.priority and owned) + then + local command = entry.cmd:match("^%s*(.-)%s*$") + local flags = { + ["no-osd"] = true, + ["osd-bar"] = true, + ["osd-msg"] = true, + ["osd-msg-bar"] = true, + ["osd-auto"] = true, + ["expand-properties"] = true, + ["raw"] = true, + ["repeatable"] = true, + ["nonrepeatable"] = true, + ["nonscalable"] = true, + ["async"] = true, + ["sync"] = true, + } + while true do + local flag, rest = command:match("^(%S+)%s+(.+)$") + if not flags[flag] then + break + end + command = rest + end + keys[signature] = { priority = entry.priority, owned = owned, ignored = command == "ignore" } + end + end + end + return keys + end + + local function sequence_conflict(binding, singles) + local code = binding.key and binding.key.code + local prefix = type(code) == "string" and code:match("^(Key[A-Z])%-Key[A-Z]$") + if not prefix then + return nil + end + local names = key_spec_to_mpv_bindings({ code = prefix, modifiers = binding.key.modifiers }) or {} + for _, name in ipairs(names) do + local existing = singles[letter_key_signature(name)] + if existing and not existing.owned and not existing.ignored then + return name + end + end + return nil + end + local function normalize_cli_args(cli_args) if type(cli_args) ~= "table" then return nil @@ -391,17 +499,23 @@ function M.create(ctx) local next_binding_names = {} state.session_binding_generation = (state.session_binding_generation or 0) + 1 local generation = state.session_binding_generation + local singles = external_single_keys() for index, binding in ipairs(artifact.bindings) do if not is_supported_binding(binding) then - subminer_log( - "warn", - "session-bindings", - "Skipped unsupported session binding from artifact" - ) + subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact") else local key_names = key_spec_to_mpv_bindings(binding.key) - if key_names then + local conflict = sequence_conflict(binding, singles) + if conflict then + local message = "Disabled sequence " + .. tostring(binding.originalKey or binding.key.code) + .. ": mpv already uses " + .. conflict + .. ". Single-key bindings take priority." + subminer_log("warn", "session-bindings", message) + show_osd(message) + elseif key_names then for key_index, key_name in ipairs(key_names) do local name = "subminer-session-binding-" .. tostring(generation) @@ -418,7 +532,8 @@ function M.create(ctx) subminer_log( "warn", "session-bindings", - "Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown") + "Skipped unsupported key code from artifact: " + .. tostring(binding.key and binding.key.code or "unknown") ) end end diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 482941f6..7a117d59 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -69,6 +69,12 @@ local ctx = { return { numericSelectionTimeoutMs = 3000, bindings = { + { + key = { code = "KeyG-KeyS", modifiers = {} }, + actionType = "session-action", + actionId = "openSubtitleSelection", + cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' }, + }, { key = { code = "KeyO", @@ -312,7 +318,8 @@ local ctx = { cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' }, }, }, - }, nil + }, + nil end, }, state = { @@ -430,17 +437,11 @@ assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action") assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag") local character_dictionary_manager = find_binding("Ctrl+d") -assert_true( - character_dictionary_manager ~= nil, - "character dictionary manager binding should be registered" -) +assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered") character_dictionary_manager.fn() local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls] -assert_true( - character_dictionary_manager_call ~= nil, - "character dictionary manager binding should invoke CLI action" -) +assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action") assert_true( character_dictionary_manager_call[2] == "--session-action", "character dictionary manager binding should use generic session action CLI flag" @@ -474,3 +475,35 @@ assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count") print("plugin session binding regression tests: OK") + +local selector = find_binding("g-s") +assert_true(selector ~= nil, "subtitle selection should override mpv g-s with a forced sequence") +selector.fn() +local selection_call = recorded.async_calls[#recorded.async_calls] +assert_true( + selection_call[3] == '{"actionId":"openSubtitleSelection"}', + "subtitle selection should dispatch its session action" +) + +local native_bindings = {} +function mp.get_property_native(name) + assert_true(name == "input-bindings", "only native input bindings should be queried") + return native_bindings +end + +for _, case in ipairs({ + { key = "g", priority = 1, enabled = false }, + { key = "G", priority = 1, enabled = true }, + { key = "Shift+g", priority = 1, enabled = true }, + { key = "Ctrl+g", priority = 1, enabled = true }, + { key = "g", priority = -1, enabled = true }, + { key = "g", priority = 1, cmd = "ignore", enabled = true }, + { key = "g", priority = 1, cmd = "no-osd ignore", enabled = true }, +}) do + native_bindings = { { key = case.key, cmd = case.cmd or "show-text single", priority = case.priority } } + recorded.bindings = {} + assert_true(bindings.reload_bindings(), "binding reload should succeed") + assert_true((find_binding("g-s") ~= nil) == case.enabled, "sequence prefix conflict: " .. case.key) +end +assert_true(#recorded.osd > 0, "native prefix conflicts should be visible") +print("plugin sequence conflict tests: OK") diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 122119c0..32f8250f 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -56,6 +56,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG; export const DEFAULT_CONFIG: ResolvedConfig = { + subtitleSelection: { enabled: false }, subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG }, subtitlePosition, keybindings, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 2c2d8c2b..270d5e74 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick< openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', openTsukihime: 'Ctrl+Shift+T', + openSubtitleSelection: 'g-s', openSubtitleGeneration: 'Ctrl+Shift+G', openSessionHelp: 'CommandOrControl+Slash', openControllerSelect: 'Alt+C', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 8803b386..09b03755 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -628,6 +628,13 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.shortcuts.openSessionHelp, description: 'Accelerator that opens the session help / keybinding cheatsheet.', }, + { + path: 'shortcuts.openSubtitleSelection', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openSubtitleSelection, + description: + 'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.', + }, { path: 'shortcuts.openSubtitleGeneration', kind: 'string', diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index e96baf66..72a1b386 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry( defaultConfig: ResolvedConfig, ): ConfigOptionRegistryEntry[] { return [ + { + path: 'subtitleSelection.enabled', + kind: 'boolean', + defaultValue: defaultConfig.subtitleSelection.enabled, + description: + 'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.', + }, ...( ['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const ).map((key) => ({ diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index d7aa3c89..6411454f 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -1,6 +1,12 @@ import { ConfigTemplateSection } from './shared'; const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + { + title: 'Subtitle Selection', + description: ['Select primary and secondary mpv subtitle tracks from the overlay.'], + notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'], + key: 'subtitleSelection', + }, { title: 'Japanese Subtitle Generation', description: [ diff --git a/src/config/hot-reload.ts b/src/config/hot-reload.ts index d54ab633..990aef76 100644 --- a/src/config/hot-reload.ts +++ b/src/config/hot-reload.ts @@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean { return path === prefix || path.startsWith(`${prefix}.`); } -const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const; +const HOT_RELOAD_ROOTS = [ + 'subtitleStyle', + 'keybindings', + 'shortcuts', + 'subtitleSidebar', + 'subtitleSelection', +] as const; const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ 'secondarySub.defaultMode', diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 4218f668..f29f7e57 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -6,6 +6,25 @@ import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyCoreDomainConfig(context: ResolveContext): void { const { src, resolved, warn } = context; + if (isObject(src.subtitleSelection)) { + const enabled = asBoolean(src.subtitleSelection.enabled); + if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled; + else if (src.subtitleSelection.enabled !== undefined) + warn( + 'subtitleSelection.enabled', + src.subtitleSelection.enabled, + resolved.subtitleSelection.enabled, + 'Expected boolean.', + ); + } else if (src.subtitleSelection !== undefined) { + warn( + 'subtitleSelection', + src.subtitleSelection, + resolved.subtitleSelection, + 'Expected object.', + ); + } + if (isObject(src.texthooker)) { const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); if (launchAtStartup !== undefined) { @@ -237,6 +256,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'openRuntimeOptions', 'openJimaku', 'openTsukihime', + 'openSubtitleSelection', 'openSubtitleGeneration', 'openSessionHelp', 'openControllerSelect', diff --git a/src/config/resolve/subtitle-selection.test.ts b/src/config/resolve/subtitle-selection.test.ts new file mode 100644 index 00000000..7a5be976 --- /dev/null +++ b/src/config/resolve/subtitle-selection.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveConfig } from '../resolve'; +import { DEFAULT_CONFIG } from '../definitions'; +import { buildConfigSettingsRegistry } from '../settings/registry'; +import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import { + compileSessionBindings, + buildPluginSessionBindingsArtifact, +} from '../../core/services/session-bindings'; + +function bindings(config: ReturnType['resolved']) { + return compileSessionBindings({ + shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG), + keybindings: config.keybindings, + platform: 'linux', + }); +} + +test('subtitle selection is opt-in and enabling it compiles g-s for mpv and the overlay', () => { + const defaults = resolveConfig({}).resolved; + assert.equal(defaults.subtitleSelection.enabled, false); + assert.equal(defaults.shortcuts.openSubtitleSelection, 'g-s'); + const find = (config: typeof defaults) => + bindings(config).bindings.find( + (binding) => + binding.actionType === 'session-action' && binding.actionId === 'openSubtitleSelection', + ); + assert.equal(find(defaults), undefined); + const enabled = resolveConfig({ subtitleSelection: { enabled: true } }).resolved; + const binding = find(enabled); + assert.ok(binding); + assert.deepEqual(binding.key, { code: 'KeyG-KeyS', modifiers: [] }); + assert.equal(bindings(enabled).warnings.length, 0); + const artifact = buildPluginSessionBindingsArtifact({ + bindings: [binding], + warnings: [], + numericSelectionTimeoutMs: 1000, + }); + assert.deepEqual(artifact.bindings[0], { + ...binding, + cliArgs: ['--session-action', '{"actionId":"openSubtitleSelection"}'], + }); + enabled.subtitleSelection.enabled = false; + assert.equal(find(enabled), undefined); +}); + +test('subtitle selection settings are validated, hot reloadable, and the shortcut can be cleared', () => { + // @ts-expect-error Config files can contain invalid values at runtime. + const { resolved, warnings } = resolveConfig({ subtitleSelection: { enabled: 'yes' } }); + assert.equal(resolved.subtitleSelection.enabled, false); + assert.equal(warnings.length, 1); + const field = buildConfigSettingsRegistry(resolved).find( + (entry) => entry.configPath === 'subtitleSelection.enabled', + ); + assert.equal(field?.category, 'behavior'); + assert.equal(field?.restartBehavior, 'hot-reload'); + const cleared = resolveConfig({ + subtitleSelection: { enabled: true }, + shortcuts: { openSubtitleSelection: null }, + }).resolved; + assert.equal(resolveConfiguredShortcuts(cleared, DEFAULT_CONFIG).openSubtitleSelection, null); +}); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 458f7468..032136d3 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -455,6 +455,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if (path.startsWith('subsync.')) { return { category: 'integrations', section: topSection(path) }; } + if (path.startsWith('subtitleSelection.')) { + return { category: 'behavior', section: 'Subtitle Selection' }; + } if (path.startsWith('subtitleGeneration.')) { return { category: 'integrations', section: 'Japanese Subtitle Generation' }; } @@ -631,6 +634,7 @@ function subsectionForPath(path: string): string | undefined { leaf === 'openRuntimeOptions' || leaf === 'openJimaku' || leaf === 'openTsukihime' || + leaf === 'openSubtitleSelection' || leaf === 'openSubtitleGeneration' || leaf === 'openSessionHelp' || leaf === 'openControllerSelect' || diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ba77264f..3bf12a2e 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -93,7 +93,7 @@ export interface IpcServiceDeps { handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; getMpvInputBindings?: () => Promise; - getSessionBindings?: () => CompiledSessionBinding[]; + getSessionBindings?: () => CompiledSessionBinding[] | Promise; getConfiguredShortcuts: () => unknown; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; @@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions { handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; getMpvInputBindings?: () => Promise; - getSessionBindings?: () => CompiledSessionBinding[]; + getSessionBindings?: () => CompiledSessionBinding[] | Promise; getConfiguredShortcuts: () => unknown; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index 2c4dc06e..724b21a4 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial = {}): Configured openRuntimeOptions: null, openJimaku: null, openTsukihime: null, + openSubtitleSelection: null, openSubtitleGeneration: null, openSessionHelp: null, openControllerSelect: null, diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 7f3d6299..c616fe85 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial = {}): Configur openRuntimeOptions: null, openJimaku: null, openTsukihime: null, + openSubtitleSelection: null, openSubtitleGeneration: null, openSessionHelp: null, openControllerSelect: null, diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index 9e22b089..b6216d01 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -43,6 +43,7 @@ function createDeps(overrides: Partial = {}) { openControllerDebug: () => calls.push('controller-debug'), openJimaku: () => calls.push('jimaku'), openTsukihime: () => calls.push('tsukihime'), + openSubtitleSelection: () => calls.push('subtitle-selection'), openSubtitleGeneration: () => calls.push('subtitle-generation'), openYoutubeTrackPicker: () => { calls.push('youtube'); diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 3e50ffcb..5c257be5 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps { openControllerDebug: () => void; openJimaku: () => void; openTsukihime: () => void; + openSubtitleSelection: () => void; openSubtitleGeneration: () => void; openYoutubeTrackPicker: () => void | Promise; openPlaylistBrowser: () => boolean | void | Promise; @@ -120,6 +121,9 @@ export async function dispatchSessionAction( case 'openTsukihime': deps.openTsukihime(); return; + case 'openSubtitleSelection': + deps.openSubtitleSelection(); + return; case 'openSubtitleGeneration': deps.openSubtitleGeneration(); return; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 0a1f1bb4..079f2dc3 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial = {}): Configur openRuntimeOptions: null, openJimaku: null, openTsukihime: null, + openSubtitleSelection: null, openSubtitleGeneration: null, openSessionHelp: null, openControllerSelect: null, @@ -707,3 +708,58 @@ test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-co assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined); assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined); }); + +test('single keys reserve sequence prefixes without reserving the second stroke', () => { + for (const key of ['g', 'Ctrl+g', 's']) { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ openSubtitleSelection: 'g-s' }), + keybindings: [createKeybinding(key, ['show-text', 'single'])], + platform: 'linux', + }); + assert.ok(result.bindings.some((binding) => binding.originalKey === key)); + assert.equal( + result.bindings.some((binding) => binding.originalKey === 'g-s'), + key !== 'g', + ); + assert.equal(result.warnings.length, key === 'g' ? 1 : 0); + if (key === 'g') assert.match(result.warnings[0]!.message, /Single-key bindings take priority/); + } +}); + +test('configured shortcuts and built-in overlay keys also reserve sequence prefixes', () => { + for (const prefix of ['g', 'y', 'v']) { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ openSubtitleSelection: `${prefix}-s`, copySubtitle: 'g' }), + keybindings: [], + platform: 'linux', + }); + assert.equal( + result.bindings.some((binding) => binding.originalKey === `${prefix}-s`), + false, + ); + assert.ok(result.bindings.some((binding) => binding.originalKey === 'g')); + assert.equal(result.warnings.length, 1); + } +}); + +test('sequence reservations follow the sidebar code and literal Shift semantics', () => { + for (const [toggleKey, disabled] of [ + ['KeyG', true], + ['g', false], + ['G', true], + ] as const) { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ openSubtitleSelection: 'Shift+g-s' }), + keybindings: [], + platform: 'linux', + rawConfig: { + ...DEFAULT_CONFIG, + subtitleSidebar: { ...DEFAULT_CONFIG.subtitleSidebar, toggleKey }, + }, + }); + assert.equal( + result.bindings.some((binding) => binding.originalKey === 'Shift+g-s'), + !disabled, + ); + } +}); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index 1a48b29e..01329489 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -12,6 +12,10 @@ import type { SessionKeySpec, } from '../../types/session-bindings'; import { SPECIAL_COMMANDS } from '../../config'; +import { + resolveSessionSequenceConflicts, + type SessionKeyReservation, +} from '../../shared/session-key-sequences'; type PlatformKeyModel = 'darwin' | 'win32' | 'linux'; @@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{ { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openJimaku', actionId: 'openJimaku' }, { key: 'openTsukihime', actionId: 'openTsukihime' }, + { key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' }, { key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' }, { key: 'openControllerSelect', actionId: 'openControllerSelect' }, @@ -81,6 +86,13 @@ function normalizeCodeToken( ): string | null { const normalized = token.trim(); if (!normalized) return null; + // Two lowercase letters use mpv's sequential-key syntax, for example g-s. + if (/^[a-z]-[a-z]$/.test(normalized)) { + return normalized + .split('-') + .map((letter) => `Key${letter.toUpperCase()}`) + .join('-'); + } if (options.allowMouseButtons === true) { const normalizedMouse = normalized.toUpperCase(); if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) { @@ -543,7 +555,37 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): { } bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)); - return { bindings, warnings }; + const reservations: SessionKeyReservation[] = [ + { key: { code: 'KeyY', modifiers: [] }, path: 'built-in y sequences' }, + { key: { code: 'KeyV', modifiers: [] }, path: 'primary subtitle visibility key' }, + { key: { code: 'KeyY', modifiers: ['ctrl'] }, path: 'lookup window toggle' }, + { key: { code: 'KeyY', modifiers: ['meta'] }, path: 'lookup window toggle' }, + { key: { code: 'KeyY', modifiers: ['ctrl', 'shift'] }, path: 'keyboard-driven mode toggle' }, + { key: { code: 'KeyY', modifiers: ['shift', 'meta'] }, path: 'keyboard-driven mode toggle' }, + ...[...candidates.values()].flatMap((drafts) => + drafts.map(({ binding }) => ({ + key: binding.key, + path: binding.sourcePath, + })), + ), + ]; + const sidebarKey = input.rawConfig?.subtitleSidebar?.toggleKey; + if (sidebarKey) { + const { key } = parseSessionBindingKey(sidebarKey, input.platform); + if (key) { + // The sidebar accepts DOM codes with either Shift state, or literal characters. + if (/^[A-Z]$/.test(sidebarKey)) key.modifiers = ['shift']; + reservations.push({ key, path: 'subtitleSidebar.toggleKey' }); + if (/^Key[A-Z]$/.test(sidebarKey)) { + reservations.push({ + key: { ...key, modifiers: ['shift'] }, + path: 'subtitleSidebar.toggleKey', + }); + } + } + } + const result = resolveSessionSequenceConflicts(bindings, reservations); + return { bindings: result.bindings, warnings: [...warnings, ...result.warnings] }; } export function buildPluginSessionBindingsArtifact(input: { diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index b889031f..22217d9f 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -16,6 +16,7 @@ export interface ConfiguredShortcuts { openRuntimeOptions: string | null | undefined; openJimaku: string | null | undefined; openTsukihime: string | null | undefined; + openSubtitleSelection: string | null | undefined; openSubtitleGeneration: string | null | undefined; openSessionHelp: string | null | undefined; openControllerSelect: string | null | undefined; @@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts( openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')), openJimaku: normalizeShortcut(shortcutValue('openJimaku')), openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')), + openSubtitleSelection: + config.subtitleSelection?.enabled === true + ? normalizeShortcut(shortcutValue('openSubtitleSelection')) + : null, openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')), openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')), openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')), diff --git a/src/main.ts b/src/main.ts index 3ba0a4ac..3988cb2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -466,6 +466,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime'; import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc'; +import { + createSubtitleSelectionRuntime, + openSubtitleSelectionModal, + registerSubtitleSelectionIpc, +} from './main/runtime/subtitle-selection'; import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createMainBootServices, type MainBootServicesResult } from './main/boot/services'; @@ -4583,6 +4588,7 @@ const { maybeStartOverlayLoadingOsd(); flushQueuedMpvOsdNotifications(); secondarySubtitleTrackController.scheduleRefresh(0); + void refreshMpvSessionBindings(); if (appState.sessionBindingsInitialized) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', @@ -5235,20 +5241,32 @@ const { }, }); -const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({ - configDir: CONFIG_DIR, - getKeybindings: () => appState.keybindings, - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getResolvedConfig: () => configService.getConfig(), - getMpvClient: () => appState.mpvClient, - setSessionBindings: (bindings) => { - appState.sessionBindings = bindings; - }, - setSessionBindingsInitialized: (initialized) => { - appState.sessionBindingsInitialized = initialized; - }, - logWarn: (message) => logger.warn(message), -}); +const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } = + createSessionBindingsRuntime({ + configDir: CONFIG_DIR, + getKeybindings: () => appState.keybindings, + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getResolvedConfig: () => configService.getConfig(), + getMpvClient: () => appState.mpvClient, + setSessionBindings: (bindings) => { + appState.sessionBindings = bindings; + }, + setSessionBindingsInitialized: (initialized) => { + appState.sessionBindingsInitialized = initialized; + }, + logWarn: (message) => logger.warn(message), + onBindingsChanged: (bindings) => + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings), + onWarning: (warning) => { + if (warning.kind !== 'conflict') return; + overlayNotificationsRuntime.showOverlayNotification({ + id: `session-binding-conflict:${warning.path}`, + title: 'Shortcut conflict', + body: warning.message, + variant: 'warning', + }); + }, + }); const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { @@ -5503,6 +5521,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro openJimaku: () => openJimakuOverlay(), openTsukihime: () => openTsukihimeOverlay(), openSessionHelp: () => openSessionHelpOverlay(), + openSubtitleSelection: () => { + if (!configService.getConfig().subtitleSelection.enabled) return; + openOverlayHostedModalWithOsd( + openSubtitleSelectionModal, + 'Subtitle selection overlay unavailable.', + 'Failed to open subtitle selection overlay.', + ); + }, openSubtitleGeneration: () => openSubtitleGenerationOverlay(), openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(), openControllerSelect: () => openControllerSelectOverlay(), @@ -5855,8 +5881,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ saveSubtitlePosition: (position) => saveSubtitlePosition(position), getMecabTokenizer: () => appState.mecabTokenizer, getKeybindings: () => appState.keybindings, - getMpvInputBindings: () => - readMpvInputBindings({ + getMpvInputBindings: async () => { + await refreshMpvSessionBindings(); + return readMpvInputBindings({ getMpvClient: () => appState.mpvClient, getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [], platform: @@ -5865,8 +5892,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ : process.platform === 'win32' ? 'win32' : 'linux', - }), - getSessionBindings: () => appState.sessionBindings, + }); + }, + getSessionBindings: async () => { + await refreshMpvSessionBindings(); + return appState.sessionBindings; + }, getConfiguredShortcuts: () => getConfiguredShortcuts(), dispatchSessionAction: (request) => dispatchSessionAction(request), getStatsToggleKey: () => configService.getConfig().stats.toggleKey, @@ -6649,6 +6680,17 @@ function setOverlayVisible(visible: boolean): void { } registerIpcRuntimeHandlers(); +registerSubtitleSelectionIpc({ + ipc: ipcMain, + isAllowedSender: (sender) => + [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some( + (window) => window && !window.isDestroyed() && window.webContents === sender, + ), + runtime: createSubtitleSelectionRuntime({ + isEnabled: () => configService.getConfig().subtitleSelection.enabled, + getMpvClient: () => appState.mpvClient, + }), +}); const subtitleGenerationRuntime = createSubtitleGenerationRuntime({ getConfig: () => configService.getConfig().subtitleGeneration, getModelDirectory: () => diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index bd99a209..9aece9d2 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied deps.setKeybindings(payload.keybindings); deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings); - if (diff.hotReloadFields.includes('shortcuts')) { + if ( + diff.hotReloadFields.includes('shortcuts') || + diff.hotReloadFields.includes('subtitleSelection') + ) { deps.refreshGlobalAndOverlayShortcuts(); } diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index 5d232081..174c6e7d 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts { openRuntimeOptions: null, openJimaku: null, openTsukihime: null, + openSubtitleSelection: null, openSubtitleGeneration: null, openSessionHelp: null, openControllerSelect: null, diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index 40b3f09a..4e1cc12b 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts { openRuntimeOptions: null, openJimaku: null, openTsukihime: null, + openSubtitleSelection: null, openSubtitleGeneration: null, openSessionHelp: null, openControllerSelect: null, diff --git a/src/main/runtime/session-bindings-runtime.test.ts b/src/main/runtime/session-bindings-runtime.test.ts index 5d17d86d..b6785b00 100644 --- a/src/main/runtime/session-bindings-runtime.test.ts +++ b/src/main/runtime/session-bindings-runtime.test.ts @@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f fs.rmSync(root, { recursive: true, force: true }); } }); + +test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-')); + const sequence: CompiledSessionBinding = { + sourcePath: 'shortcuts.openSubtitleSelection', + originalKey: 'g-s', + key: { code: 'KeyG-KeyS', modifiers: [] }, + actionType: 'session-action', + actionId: 'openSubtitleSelection', + }; + let nativeKeys: unknown = []; + let failDiscovery = false; + let published: CompiledSessionBinding[] = []; + const events: CompiledSessionBinding[][] = []; + const warnings: string[] = []; + const client = { + connected: true, + send: () => {}, + requestProperty: async () => { + if (failDiscovery) throw new Error('temporarily unavailable'); + return nativeKeys; + }, + }; + const runtime = createSessionBindingsRuntime({ + configDir: root, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never, + getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig, + getMpvClient: () => client, + setSessionBindings: (bindings) => { + published = bindings; + }, + setSessionBindingsInitialized: () => {}, + logWarn: () => {}, + onBindingsChanged: (bindings) => events.push(bindings), + onWarning: (warning) => warnings.push(warning.message), + }); + const readArtifact = () => + JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8')); + try { + runtime.persistSessionBindings([sequence]); + nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }]; + await runtime.refreshMpvSessionBindings(); + assert.deepEqual(published, []); + assert.deepEqual(events.at(-1), readArtifact().bindings); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /mpv input binding "g"/); + await runtime.refreshMpvSessionBindings(); + assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop'); + assert.equal(warnings.length, 1); + failDiscovery = true; + await runtime.refreshMpvSessionBindings(); + assert.deepEqual(published, [], 'failed discovery retains the known conflict'); + failDiscovery = false; + nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }]; + await runtime.refreshMpvSessionBindings(); + assert.deepEqual(published, [sequence]); + assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS'); + assert.deepEqual(readArtifact().warnings, []); + client.connected = false; + await runtime.refreshMpvSessionBindings(); + assert.deepEqual(published, [sequence]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/main/runtime/session-bindings-runtime.ts b/src/main/runtime/session-bindings-runtime.ts index 998331ac..8912d632 100644 --- a/src/main/runtime/session-bindings-runtime.ts +++ b/src/main/runtime/session-bindings-runtime.ts @@ -6,16 +6,26 @@ import { import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types'; import { writeSessionBindingsArtifact } from './session-bindings-artifact'; +import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings'; +import { + reserveMpvSequencePrefixes, + resolveSessionSequenceConflicts, +} from '../../shared/session-key-sequences'; +import type { SessionBindingWarning } from '../../types/session-bindings'; export interface SessionBindingsRuntimeDeps { configDir: string; getKeybindings: () => Keybinding[]; getConfiguredShortcuts: () => ConfiguredShortcuts; getResolvedConfig: () => ResolvedConfig; - getMpvClient: () => MpvRuntimeClientLike | null; + getMpvClient: () => + | (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise }) + | null; setSessionBindings: (bindings: CompiledSessionBinding[]) => void; setSessionBindingsInitialized: (initialized: boolean) => void; logWarn: (message: string, details?: unknown) => void; + onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void; + onWarning?: (warning: SessionBindingWarning) => void; } export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): { @@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): warnings?: ReturnType['warnings'], ) => void; refreshCurrentSessionBindings: () => void; + refreshMpvSessionBindings: () => Promise; } { + let sourceBindings: CompiledSessionBinding[] = []; + let sourceWarnings: SessionBindingWarning[] = []; + let nativeSnapshot: { + client: ReturnType; + keys: string[]; + } | null = null; + let pending: { + client: ReturnType; + promise: Promise; + } | null = null; + let publishedSignature: string | null = null; + let reportedWarnings = new Set(); function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' { if (process.platform === 'darwin') return 'darwin'; if (process.platform === 'win32') return 'win32'; @@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): bindings: CompiledSessionBinding[], warnings: ReturnType['warnings'] = [], ): void { + sourceBindings = bindings; + sourceWarnings = warnings; + publishBindings(); + } + + function publishBindings(): void { + const client = deps.getMpvClient(); + const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : []; + const result = resolveSessionSequenceConflicts( + sourceBindings, + reserveMpvSequencePrefixes(keys), + ); + const warnings = [...sourceWarnings, ...result.warnings]; + const signature = JSON.stringify([ + result.bindings, + warnings, + deps.getConfiguredShortcuts().multiCopyTimeoutMs, + ]); + if (signature === publishedSignature) return; const artifact = buildPluginSessionBindingsArtifact({ - bindings, + bindings: result.bindings, warnings, numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs, }); @@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): deps.logWarn('[session-bindings] Failed to write session bindings artifact'); throw error; } - deps.setSessionBindings(bindings); + publishedSignature = signature; + deps.setSessionBindings(result.bindings); deps.setSessionBindingsInitialized(true); + const nextWarnings = new Set(warnings.map((warning) => warning.message)); + for (const warning of warnings) { + if (reportedWarnings.has(warning.message)) continue; + deps.logWarn(`[session-bindings] ${warning.message}`); + deps.onWarning?.(warning); + } + reportedWarnings = nextWarnings; const mpvClient = deps.getMpvClient(); if (mpvClient?.connected) { try { @@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error); } } + deps.onBindingsChanged?.(result.bindings); + } + + async function refreshMpvSessionBindings(): Promise { + const client = deps.getMpvClient(); + if (!client?.connected) { + nativeSnapshot = null; + publishBindings(); + return; + } + if (pending?.client === client) return pending.promise; + const promise = (async () => { + try { + const raw = await client.requestProperty('input-bindings'); + if (client !== deps.getMpvClient() || !client.connected) return; + nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) }; + publishBindings(); + } catch { + // Keep the last successful snapshot if discovery is temporarily unavailable. + } + })(); + const request = { client, promise }; + pending = request; + try { + await promise; + } finally { + if (pending === request) pending = null; + } } function refreshCurrentSessionBindings(): void { const compiled = compileCurrentSessionBindings(); - for (const warning of compiled.warnings) { - deps.logWarn(`[session-bindings] ${warning.message}`); - } persistSessionBindings(compiled.bindings, compiled.warnings); + void refreshMpvSessionBindings(); } - return { persistSessionBindings, refreshCurrentSessionBindings }; + return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings }; } diff --git a/src/main/runtime/subtitle-selection.test.ts b/src/main/runtime/subtitle-selection.test.ts new file mode 100644 index 00000000..fc42db37 --- /dev/null +++ b/src/main/runtime/subtitle-selection.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSubtitleSelectionRuntime } from './subtitle-selection'; + +function setup() { + let enabled = true; + const properties = new Map([ + ['path', '/video.mkv'], + [ + 'track-list', + [ + { id: 1, type: 'audio' }, + { id: 2, type: 'sub', title: 'Japanese', lang: 'ja', codec: 'ass' }, + { id: 3, type: 'sub', title: 'English', lang: 'en', external: true }, + { id: '4', type: 'sub' }, + ], + ], + ['sid', 2], + ['secondary-sid', 3], + ]); + const commands: unknown[][] = []; + const client = { + connected: true, + requestProperty: async (name: string) => properties.get(name), + request: async (command: unknown[]) => { + commands.push(command); + return { error: 'success' }; + }, + }; + const runtime = createSubtitleSelectionRuntime({ + isEnabled: () => enabled, + getMpvClient: () => client, + }); + return { + runtime, + properties, + commands, + client, + disable: () => { + enabled = false; + }, + }; +} + +test('subtitle selector lists only valid subtitle tracks and current selections', async () => { + const { runtime, properties } = setup(); + assert.deepEqual(await runtime.getState(), { + mediaPath: '/video.mkv', + primary: 2, + secondary: 3, + tracks: [ + { id: 2, label: '#2 · Japanese · ja · ass' }, + { id: 3, label: '#3 · English · en · external' }, + ], + }); + properties.set('sid', 'no'); + properties.set('secondary-sid', false); + const state = await runtime.getState(); + assert.equal(state.primary, null); + assert.equal(state.secondary, null); +}); + +test('subtitle selector swaps tracks and supports disabling both tracks', async () => { + const { runtime, commands } = setup(); + await runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }); + assert.deepEqual(commands, [ + ['set_property', 'secondary-sid', 'no'], + ['set_property', 'sid', 3], + ['set_property', 'secondary-sid', 2], + ]); + commands.length = 0; + await runtime.apply({ mediaPath: '/video.mkv', primary: null, secondary: null }); + assert.ok(commands.every((command) => command[2] === 'no')); +}); + +test('subtitle selector rejects stale media, unavailable tracks, duplicate tracks and malformed requests without mutation', async () => { + const { runtime, commands } = setup(); + for (const request of [ + { mediaPath: '/other.mkv', primary: 2, secondary: 3 }, + { mediaPath: '/video.mkv', primary: 99, secondary: null }, + { mediaPath: '/video.mkv', primary: 2, secondary: 2 }, + { mediaPath: '/video.mkv', primary: '2', secondary: null }, + { mediaPath: '/video.mkv', primary: -1, secondary: null }, + null, + ]) + await assert.rejects(runtime.apply(request)); + assert.deepEqual(commands, []); +}); + +test('subtitle selector gates access on config and connection and propagates mpv failures', async () => { + const { runtime, client, disable } = setup(); + client.request = async () => ({ error: 'property unavailable' }); + await assert.rejects( + runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }), + /property unavailable/, + ); + client.connected = false; + await assert.rejects(runtime.getState(), /Connect to mpv/); + disable(); + await assert.rejects(runtime.getState(), /Enable subtitle selection/); +}); diff --git a/src/main/runtime/subtitle-selection.ts b/src/main/runtime/subtitle-selection.ts new file mode 100644 index 00000000..19aca325 --- /dev/null +++ b/src/main/runtime/subtitle-selection.ts @@ -0,0 +1,117 @@ +import type { IpcMain, WebContents } from 'electron'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { + parseSubtitleSelectionRequest, + type SubtitleSelectionState, +} from '../../shared/subtitle-selection'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +interface SelectionMpvClient { + connected: boolean; + requestProperty: (name: string) => Promise; + request: (command: unknown[]) => Promise<{ error?: string }>; +} + +export function openSubtitleSelectionModal( + deps: Parameters[0] & Parameters[0], +): Promise { + return retryOverlayModalOpen(deps, { + modal: 'subtitle-selection', + timeoutMs: 1500, + retryWarning: 'Subtitle selection modal did not acknowledge opening; retrying.', + sendOpen: () => + openOverlayHostedModal(deps, { + channel: IPC_CHANNELS.event.subtitleSelectionOpen, + modal: 'subtitle-selection', + preferModalWindow: true, + }), + }); +} + +export function createSubtitleSelectionRuntime(deps: { + isEnabled: () => boolean; + getMpvClient: () => SelectionMpvClient | null; +}) { + function getClient(): SelectionMpvClient { + if (!deps.isEnabled()) throw new Error('Enable subtitle selection in Settings first.'); + const client = deps.getMpvClient(); + if (!client?.connected) throw new Error('Connect to mpv first.'); + return client; + } + + async function readState(client: SelectionMpvClient): Promise { + const mediaPath = await client.requestProperty('path'); + if (typeof mediaPath !== 'string' || !mediaPath) throw new Error('Open a video first.'); + const [rawTracks, primary, secondary] = await Promise.all([ + client.requestProperty('track-list'), + client.requestProperty('sid'), + client.requestProperty('secondary-sid'), + ]); + const tracks: SubtitleSelectionState['tracks'] = []; + const candidates: unknown[] = Array.isArray(rawTracks) ? rawTracks : []; + for (const track of candidates) { + if ( + typeof track !== 'object' || + track === null || + !('type' in track) || + track.type !== 'sub' || + !('id' in track) || + typeof track.id !== 'number' || + !Number.isSafeInteger(track.id) || + track.id <= 0 + ) + continue; + const details = [ + 'title' in track ? track.title : undefined, + 'lang' in track ? track.lang : undefined, + 'codec' in track ? track.codec : undefined, + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + if ('external' in track && track.external === true) details.push('external'); + tracks.push({ id: track.id, label: `#${track.id} · ${details.join(' · ') || 'Subtitle'}` }); + } + if ((await client.requestProperty('path')) !== mediaPath) + throw new Error('The video changed. Reopen subtitle selection.'); + const selected = (value: unknown): number | null => + tracks.find((track) => track.id === value)?.id ?? null; + return { mediaPath, tracks, primary: selected(primary), secondary: selected(secondary) }; + } + + async function apply(value: unknown): Promise { + const selection = parseSubtitleSelectionRequest(value); + const client = getClient(); + const current = await readState(client); + if (current.mediaPath !== selection.mediaPath) + throw new Error('The video changed. Reopen subtitle selection.'); + for (const id of [selection.primary, selection.secondary]) { + if (id !== null && !current.tracks.some((track) => track.id === id)) + throw new Error('A selected track is no longer available. Reopen subtitle selection.'); + } + const set = async (property: string, id: number | null): Promise => { + const response = await client.request(['set_property', property, id ?? 'no']); + if (response.error && response.error !== 'success') throw new Error(response.error); + }; + // Clear secondary first so swapping the two tracks works in mpv. + await set('secondary-sid', null); + await set('sid', selection.primary); + await set('secondary-sid', selection.secondary); + } + + return { getState: async () => readState(getClient()), apply }; +} + +export function registerSubtitleSelectionIpc(deps: { + ipc: Pick; + isAllowedSender: (sender: WebContents) => boolean; + runtime: ReturnType; +}): void { + deps.ipc.handle(IPC_CHANNELS.request.getSubtitleSelection, (event) => { + if (!deps.isAllowedSender(event.sender)) + throw new Error('Subtitle selection requires the overlay.'); + return deps.runtime.getState(); + }); + deps.ipc.handle(IPC_CHANNELS.request.applySubtitleSelection, (event, value: unknown) => { + if (!deps.isAllowedSender(event.sender)) + throw new Error('Subtitle selection requires the overlay.'); + return deps.runtime.apply(value); + }); +} diff --git a/src/preload.ts b/src/preload.ts index d9cc260f..16d9e7b5 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -81,6 +81,7 @@ import { IPC_CHANNELS } from './shared/ipc/contracts'; import type { SubtitleGenerationProgress } from './shared/subtitle-generation'; const overlayLayer = resolveOverlayLayerFromArgv(process.argv); +const onSubtitleSelectionOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleSelectionOpen); const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen); type EmptyListener = () => void; @@ -463,6 +464,11 @@ const electronAPI: ElectronAPI = { ) as Promise, getSubtitleStyle: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle), + onSubtitleSelectionOpen, + getSubtitleSelection: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSelection), + applySubtitleSelection: ( + request: import('./shared/subtitle-selection').SubtitleSelectionRequest, + ) => ipcRenderer.invoke(IPC_CHANNELS.request.applySubtitleSelection, request), onSubsyncManualOpen: onSubsyncManualOpenEvent, runSubsyncManual: (request: SubsyncManualRunRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), @@ -580,6 +586,14 @@ const electronAPI: ElectronAPI = { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement); }, + onSessionBindingsChanged: ( + callback: (bindings: import('./types').CompiledSessionBinding[]) => void, + ) => { + ipcRenderer.on( + IPC_CHANNELS.event.sessionBindingsChanged, + (_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings), + ); + }, onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { ipcRenderer.on( IPC_CHANNELS.event.configHotReload, diff --git a/src/renderer/controller-interaction-blocking.ts b/src/renderer/controller-interaction-blocking.ts index 63573962..02b6b425 100644 --- a/src/renderer/controller-interaction-blocking.ts +++ b/src/renderer/controller-interaction-blocking.ts @@ -4,6 +4,7 @@ type ControllerInteractionModalState = { jimakuModalOpen: boolean; kikuModalOpen: boolean; runtimeOptionsModalOpen: boolean; + subtitleSelectionModalOpen?: boolean; subsyncModalOpen: boolean; subtitleGenerationModalOpen?: boolean; youtubePickerModalOpen: boolean; @@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal state.jimakuModalOpen || state.kikuModalOpen || state.runtimeOptionsModalOpen || + state.subtitleSelectionModalOpen || state.subsyncModalOpen || Boolean(state.subtitleGenerationModalOpen) || state.youtubePickerModalOpen || diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 79124cc6..a8dfcb5d 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts { openRuntimeOptions: null, openJimaku: null, openTsukihime: null, + openSubtitleSelection: null, openSubtitleGeneration: null, openSessionHelp: null, openControllerSelect: null, @@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async ( testGlobals.restore(); } }); + +test('session binding: g-s opens subtitle selection only after the complete sequence', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openSubtitleSelection', + originalKey: 'g-s', + key: { code: 'KeyG-KeyS', modifiers: [] }, + actionType: 'session-action', + actionId: 'openSubtitleSelection', + }, + ]); + testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' }); + testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' }); + assert.deepEqual(testGlobals.sessionActions, []); + testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' }); + assert.deepEqual(testGlobals.sessionActions, [ + { actionId: 'openSubtitleSelection', payload: undefined }, + ]); + testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' }); + handlers.updateSessionBindings([]); + testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' }); + assert.equal(testGlobals.sessionActions.length, 1); + } finally { + testGlobals.restore(); + } +}); + +test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'sequence', + originalKey: 'g-s', + key: { code: 'KeyG-KeyS', modifiers: [] }, + actionType: 'session-action', + actionId: 'openSubtitleSelection', + }, + { + sourcePath: 'single', + originalKey: 'g', + key: { code: 'KeyG', modifiers: [] }, + actionType: 'mpv-command', + command: ['show-text', 'single'], + }, + ]); + testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' }); + assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]); + testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' }); + assert.deepEqual(testGlobals.sessionActions, []); + } finally { + testGlobals.restore(); + } +}); + +test('an unfinished built-in y chord cannot start a configured sequence', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'sequence', + originalKey: 'g-s', + key: { code: 'KeyG-KeyS', modifiers: [] }, + actionType: 'session-action', + actionId: 'openSubtitleSelection', + }, + ]); + testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' }); + testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' }); + testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' }); + assert.deepEqual(testGlobals.sessionActions, []); + testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' }); + testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' }); + assert.equal(testGlobals.sessionActions.length, 1); + } finally { + testGlobals.restore(); + } +}); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 1e1bbe19..68db6a1f 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -15,6 +15,7 @@ export function createKeyboardHandlers( handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean; handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean; + handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean; handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; @@ -133,7 +134,10 @@ export function createKeyboardHandlers( updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); } + let pendingSequence: { prefix: string; expires: number } | null = null; + function updateSessionBindings(bindings: CompiledSessionBinding[]): void { + pendingSequence = null; ctx.state.sessionBindings = bindings; ctx.state.sessionBindingMap = new Map( bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]), @@ -1049,7 +1053,10 @@ export function createKeyboardHandlers( window.addEventListener('focus', () => { void importedMpvBindings.refresh(); }); - window.addEventListener('blur', importedMpvBindings.releaseAll); + window.addEventListener('blur', () => { + pendingSequence = null; + importedMpvBindings.releaseAll(); + }); window.addEventListener('beforeunload', () => { clearTimeout(lateScriptRefresh); importedMpvBindings.dispose(); @@ -1103,6 +1110,13 @@ export function createKeyboardHandlers( ); document.addEventListener('keydown', (e: KeyboardEvent) => { + const sequence = pendingSequence; + pendingSequence = null; + if (ctx.state.subtitleSelectionModalOpen) { + pendingSequence = null; + options.handleSubtitleSelectionKeydown?.(e); + return; + } if (ctx.state.subtitleGenerationModalOpen) { options.handleSubtitleGenerationKeydown?.(e); return; @@ -1187,6 +1201,7 @@ export function createKeyboardHandlers( } if (isTextEntryTarget(e.target)) { + pendingSequence = null; return; } @@ -1194,6 +1209,16 @@ export function createKeyboardHandlers( return; } + const sequenceKey = keyEventToString(e); + if (sequence && !ctx.state.chordPending && Date.now() <= sequence.expires && !e.repeat) { + const binding = ctx.state.sessionBindingMap.get(`${sequence.prefix}-${sequenceKey}`); + if (binding) { + e.preventDefault(); + dispatchSessionBinding(binding); + return; + } + } + if (isStatsOverlayToggle(e)) { e.preventDefault(); window.electronAPI.toggleStatsOverlay(); @@ -1276,6 +1301,16 @@ export function createKeyboardHandlers( dispatchSessionBinding(binding); return; } + if ( + !e.repeat && + ctx.state.sessionBindings.some((binding) => + keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`), + ) + ) { + pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 }; + e.preventDefault(); + return; + } if ( keyboardConfigLoaded && !ctx.state.playlistBrowserModalOpen && diff --git a/src/renderer/index.html b/src/renderer/index.html index 52f75c77..4ec0f0f3 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -833,6 +833,43 @@ + +