mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca673735ba
|
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Documented the subtitle selector setting, shortcut override, and primary/secondary track controls.
|
||||||
@@ -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.
|
||||||
@@ -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
|
// Japanese Subtitle Generation
|
||||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
// 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.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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).
|
"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.
|
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||||
|
|||||||
@@ -1117,6 +1117,14 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
|||||||
|
|
||||||
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||||
|
|
||||||
|
## 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
|
## External integrations
|
||||||
|
|
||||||
### Jimaku
|
### Jimaku
|
||||||
|
|||||||
@@ -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
|
// Japanese Subtitle Generation
|
||||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
// 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.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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).
|
"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.
|
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||||
|
|||||||
@@ -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+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
|
||||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||||
|
| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
|
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
|
||||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
|
| `\` | 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`.
|
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.
|
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.
|
In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
|
||||||
|
|||||||
@@ -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.
|
- `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 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.
|
- `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/renderer/` owns overlay rendering and input behavior.
|
||||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
- `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.
|
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function key_code_to_mpv_name(code)
|
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
|
if KEY_NAME_MAP[code] then
|
||||||
return KEY_NAME_MAP[code]
|
return KEY_NAME_MAP[code]
|
||||||
end
|
end
|
||||||
@@ -187,6 +191,110 @@ function M.create(ctx)
|
|||||||
return bindings
|
return bindings
|
||||||
end
|
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)
|
local function normalize_cli_args(cli_args)
|
||||||
if type(cli_args) ~= "table" then
|
if type(cli_args) ~= "table" then
|
||||||
return nil
|
return nil
|
||||||
@@ -391,17 +499,23 @@ function M.create(ctx)
|
|||||||
local next_binding_names = {}
|
local next_binding_names = {}
|
||||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||||
local generation = state.session_binding_generation
|
local generation = state.session_binding_generation
|
||||||
|
local singles = external_single_keys()
|
||||||
|
|
||||||
for index, binding in ipairs(artifact.bindings) do
|
for index, binding in ipairs(artifact.bindings) do
|
||||||
if not is_supported_binding(binding) then
|
if not is_supported_binding(binding) then
|
||||||
subminer_log(
|
subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
|
||||||
"warn",
|
|
||||||
"session-bindings",
|
|
||||||
"Skipped unsupported session binding from artifact"
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
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
|
for key_index, key_name in ipairs(key_names) do
|
||||||
local name = "subminer-session-binding-"
|
local name = "subminer-session-binding-"
|
||||||
.. tostring(generation)
|
.. tostring(generation)
|
||||||
@@ -418,7 +532,8 @@ function M.create(ctx)
|
|||||||
subminer_log(
|
subminer_log(
|
||||||
"warn",
|
"warn",
|
||||||
"session-bindings",
|
"session-bindings",
|
||||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
"Skipped unsupported key code from artifact: "
|
||||||
|
.. tostring(binding.key and binding.key.code or "unknown")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ local ctx = {
|
|||||||
return {
|
return {
|
||||||
numericSelectionTimeoutMs = 3000,
|
numericSelectionTimeoutMs = 3000,
|
||||||
bindings = {
|
bindings = {
|
||||||
|
{
|
||||||
|
key = { code = "KeyG-KeyS", modifiers = {} },
|
||||||
|
actionType = "session-action",
|
||||||
|
actionId = "openSubtitleSelection",
|
||||||
|
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key = {
|
key = {
|
||||||
code = "KeyO",
|
code = "KeyO",
|
||||||
@@ -312,7 +318,8 @@ local ctx = {
|
|||||||
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
|
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
},
|
||||||
|
nil
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
state = {
|
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")
|
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
|
||||||
|
|
||||||
local character_dictionary_manager = find_binding("Ctrl+d")
|
local character_dictionary_manager = find_binding("Ctrl+d")
|
||||||
assert_true(
|
assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
|
||||||
character_dictionary_manager ~= nil,
|
|
||||||
"character dictionary manager binding should be registered"
|
|
||||||
)
|
|
||||||
|
|
||||||
character_dictionary_manager.fn()
|
character_dictionary_manager.fn()
|
||||||
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
|
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
|
||||||
assert_true(
|
assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
|
||||||
character_dictionary_manager_call ~= nil,
|
|
||||||
"character dictionary manager binding should invoke CLI action"
|
|
||||||
)
|
|
||||||
assert_true(
|
assert_true(
|
||||||
character_dictionary_manager_call[2] == "--session-action",
|
character_dictionary_manager_call[2] == "--session-action",
|
||||||
"character dictionary manager binding should use generic session action CLI flag"
|
"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")
|
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||||
|
|
||||||
print("plugin session binding regression tests: OK")
|
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")
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
|||||||
const { stats } = STATS_DEFAULT_CONFIG;
|
const { stats } = STATS_DEFAULT_CONFIG;
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||||
|
subtitleSelection: { enabled: false },
|
||||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||||
subtitlePosition,
|
subtitlePosition,
|
||||||
keybindings,
|
keybindings,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||||
openJimaku: 'Ctrl+Shift+J',
|
openJimaku: 'Ctrl+Shift+J',
|
||||||
openTsukihime: 'Ctrl+Shift+T',
|
openTsukihime: 'Ctrl+Shift+T',
|
||||||
|
openSubtitleSelection: 'g-s',
|
||||||
openSubtitleGeneration: 'Ctrl+Shift+G',
|
openSubtitleGeneration: 'Ctrl+Shift+G',
|
||||||
openSessionHelp: 'CommandOrControl+Slash',
|
openSessionHelp: 'CommandOrControl+Slash',
|
||||||
openControllerSelect: 'Alt+C',
|
openControllerSelect: 'Alt+C',
|
||||||
|
|||||||
@@ -628,6 +628,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
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',
|
path: 'shortcuts.openSubtitleGeneration',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
): ConfigOptionRegistryEntry[] {
|
): ConfigOptionRegistryEntry[] {
|
||||||
return [
|
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
|
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
|
||||||
).map((key) => ({
|
).map((key) => ({
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { ConfigTemplateSection } from './shared';
|
import { ConfigTemplateSection } from './shared';
|
||||||
|
|
||||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
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',
|
title: 'Japanese Subtitle Generation',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
|
|||||||
return path === prefix || path.startsWith(`${prefix}.`);
|
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 = [
|
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||||
'secondarySub.defaultMode',
|
'secondarySub.defaultMode',
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
|||||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
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)) {
|
if (isObject(src.texthooker)) {
|
||||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||||
if (launchAtStartup !== undefined) {
|
if (launchAtStartup !== undefined) {
|
||||||
@@ -237,6 +256,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
'openTsukihime',
|
'openTsukihime',
|
||||||
|
'openSubtitleSelection',
|
||||||
'openSubtitleGeneration',
|
'openSubtitleGeneration',
|
||||||
'openSessionHelp',
|
'openSessionHelp',
|
||||||
'openControllerSelect',
|
'openControllerSelect',
|
||||||
|
|||||||
@@ -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<typeof resolveConfig>['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);
|
||||||
|
});
|
||||||
@@ -455,6 +455,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('subsync.')) {
|
if (path.startsWith('subsync.')) {
|
||||||
return { category: 'integrations', section: topSection(path) };
|
return { category: 'integrations', section: topSection(path) };
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('subtitleSelection.')) {
|
||||||
|
return { category: 'behavior', section: 'Subtitle Selection' };
|
||||||
|
}
|
||||||
if (path.startsWith('subtitleGeneration.')) {
|
if (path.startsWith('subtitleGeneration.')) {
|
||||||
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
|
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
|
||||||
}
|
}
|
||||||
@@ -631,6 +634,7 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
leaf === 'openRuntimeOptions' ||
|
leaf === 'openRuntimeOptions' ||
|
||||||
leaf === 'openJimaku' ||
|
leaf === 'openJimaku' ||
|
||||||
leaf === 'openTsukihime' ||
|
leaf === 'openTsukihime' ||
|
||||||
|
leaf === 'openSubtitleSelection' ||
|
||||||
leaf === 'openSubtitleGeneration' ||
|
leaf === 'openSubtitleGeneration' ||
|
||||||
leaf === 'openSessionHelp' ||
|
leaf === 'openSessionHelp' ||
|
||||||
leaf === 'openControllerSelect' ||
|
leaf === 'openControllerSelect' ||
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||||
getSessionBindings?: () => CompiledSessionBinding[];
|
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||||
getStatsToggleKey: () => string;
|
getStatsToggleKey: () => string;
|
||||||
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||||
getSessionBindings?: () => CompiledSessionBinding[];
|
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||||
getStatsToggleKey: () => string;
|
getStatsToggleKey: () => string;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
openControllerDebug: () => calls.push('controller-debug'),
|
openControllerDebug: () => calls.push('controller-debug'),
|
||||||
openJimaku: () => calls.push('jimaku'),
|
openJimaku: () => calls.push('jimaku'),
|
||||||
openTsukihime: () => calls.push('tsukihime'),
|
openTsukihime: () => calls.push('tsukihime'),
|
||||||
|
openSubtitleSelection: () => calls.push('subtitle-selection'),
|
||||||
openSubtitleGeneration: () => calls.push('subtitle-generation'),
|
openSubtitleGeneration: () => calls.push('subtitle-generation'),
|
||||||
openYoutubeTrackPicker: () => {
|
openYoutubeTrackPicker: () => {
|
||||||
calls.push('youtube');
|
calls.push('youtube');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
|
|||||||
openControllerDebug: () => void;
|
openControllerDebug: () => void;
|
||||||
openJimaku: () => void;
|
openJimaku: () => void;
|
||||||
openTsukihime: () => void;
|
openTsukihime: () => void;
|
||||||
|
openSubtitleSelection: () => void;
|
||||||
openSubtitleGeneration: () => void;
|
openSubtitleGeneration: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||||
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
|
|||||||
case 'openTsukihime':
|
case 'openTsukihime':
|
||||||
deps.openTsukihime();
|
deps.openTsukihime();
|
||||||
return;
|
return;
|
||||||
|
case 'openSubtitleSelection':
|
||||||
|
deps.openSubtitleSelection();
|
||||||
|
return;
|
||||||
case 'openSubtitleGeneration':
|
case 'openSubtitleGeneration':
|
||||||
deps.openSubtitleGeneration();
|
deps.openSubtitleGeneration();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: 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('copySubtitleMultiple')?.cliArgs, undefined);
|
||||||
assert.equal(byActionId.get('mineSentenceMultiple')?.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type {
|
|||||||
SessionKeySpec,
|
SessionKeySpec,
|
||||||
} from '../../types/session-bindings';
|
} from '../../types/session-bindings';
|
||||||
import { SPECIAL_COMMANDS } from '../../config';
|
import { SPECIAL_COMMANDS } from '../../config';
|
||||||
|
import {
|
||||||
|
resolveSessionSequenceConflicts,
|
||||||
|
type SessionKeyReservation,
|
||||||
|
} from '../../shared/session-key-sequences';
|
||||||
|
|
||||||
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
|
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
|||||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||||
|
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
|
||||||
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
|
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
|
||||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||||
@@ -81,6 +86,13 @@ function normalizeCodeToken(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const normalized = token.trim();
|
const normalized = token.trim();
|
||||||
if (!normalized) return null;
|
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) {
|
if (options.allowMouseButtons === true) {
|
||||||
const normalizedMouse = normalized.toUpperCase();
|
const normalizedMouse = normalized.toUpperCase();
|
||||||
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
|
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));
|
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: {
|
export function buildPluginSessionBindingsArtifact(input: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: string | null | undefined;
|
openRuntimeOptions: string | null | undefined;
|
||||||
openJimaku: string | null | undefined;
|
openJimaku: string | null | undefined;
|
||||||
openTsukihime: string | null | undefined;
|
openTsukihime: string | null | undefined;
|
||||||
|
openSubtitleSelection: string | null | undefined;
|
||||||
openSubtitleGeneration: string | null | undefined;
|
openSubtitleGeneration: string | null | undefined;
|
||||||
openSessionHelp: string | null | undefined;
|
openSessionHelp: string | null | undefined;
|
||||||
openControllerSelect: string | null | undefined;
|
openControllerSelect: string | null | undefined;
|
||||||
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
|
|||||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||||
|
openSubtitleSelection:
|
||||||
|
config.subtitleSelection?.enabled === true
|
||||||
|
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
|
||||||
|
: null,
|
||||||
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
||||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||||
|
|||||||
+48
-6
@@ -466,6 +466,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
|||||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
||||||
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
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 { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||||
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
||||||
@@ -4583,6 +4588,7 @@ const {
|
|||||||
maybeStartOverlayLoadingOsd();
|
maybeStartOverlayLoadingOsd();
|
||||||
flushQueuedMpvOsdNotifications();
|
flushQueuedMpvOsdNotifications();
|
||||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||||
|
void refreshMpvSessionBindings();
|
||||||
if (appState.sessionBindingsInitialized) {
|
if (appState.sessionBindingsInitialized) {
|
||||||
sendMpvCommandRuntime(appState.mpvClient, [
|
sendMpvCommandRuntime(appState.mpvClient, [
|
||||||
'script-message',
|
'script-message',
|
||||||
@@ -5235,7 +5241,8 @@ const {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
|
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
|
||||||
|
createSessionBindingsRuntime({
|
||||||
configDir: CONFIG_DIR,
|
configDir: CONFIG_DIR,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
@@ -5248,7 +5255,18 @@ const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionB
|
|||||||
appState.sessionBindingsInitialized = initialized;
|
appState.sessionBindingsInitialized = initialized;
|
||||||
},
|
},
|
||||||
logWarn: (message) => logger.warn(message),
|
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({
|
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||||
appendToMpvLogMainDeps: {
|
appendToMpvLogMainDeps: {
|
||||||
@@ -5503,6 +5521,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
openJimaku: () => openJimakuOverlay(),
|
openJimaku: () => openJimakuOverlay(),
|
||||||
openTsukihime: () => openTsukihimeOverlay(),
|
openTsukihime: () => openTsukihimeOverlay(),
|
||||||
openSessionHelp: () => openSessionHelpOverlay(),
|
openSessionHelp: () => openSessionHelpOverlay(),
|
||||||
|
openSubtitleSelection: () => {
|
||||||
|
if (!configService.getConfig().subtitleSelection.enabled) return;
|
||||||
|
openOverlayHostedModalWithOsd(
|
||||||
|
openSubtitleSelectionModal,
|
||||||
|
'Subtitle selection overlay unavailable.',
|
||||||
|
'Failed to open subtitle selection overlay.',
|
||||||
|
);
|
||||||
|
},
|
||||||
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
||||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||||
openControllerSelect: () => openControllerSelectOverlay(),
|
openControllerSelect: () => openControllerSelectOverlay(),
|
||||||
@@ -5855,8 +5881,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getMpvInputBindings: () =>
|
getMpvInputBindings: async () => {
|
||||||
readMpvInputBindings({
|
await refreshMpvSessionBindings();
|
||||||
|
return readMpvInputBindings({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
||||||
platform:
|
platform:
|
||||||
@@ -5865,8 +5892,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
: process.platform === 'win32'
|
: process.platform === 'win32'
|
||||||
? 'win32'
|
? 'win32'
|
||||||
: 'linux',
|
: 'linux',
|
||||||
}),
|
});
|
||||||
getSessionBindings: () => appState.sessionBindings,
|
},
|
||||||
|
getSessionBindings: async () => {
|
||||||
|
await refreshMpvSessionBindings();
|
||||||
|
return appState.sessionBindings;
|
||||||
|
},
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||||
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||||
@@ -6649,6 +6680,17 @@ function setOverlayVisible(visible: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerIpcRuntimeHandlers();
|
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({
|
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||||
getModelDirectory: () =>
|
getModelDirectory: () =>
|
||||||
|
|||||||
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
|||||||
deps.setKeybindings(payload.keybindings);
|
deps.setKeybindings(payload.keybindings);
|
||||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||||
|
|
||||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
if (
|
||||||
|
diff.hotReloadFields.includes('shortcuts') ||
|
||||||
|
diff.hotReloadFields.includes('subtitleSelection')
|
||||||
|
) {
|
||||||
deps.refreshGlobalAndOverlayShortcuts();
|
deps.refreshGlobalAndOverlayShortcuts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
|
|||||||
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
|
|||||||
fs.rmSync(root, { recursive: true, force: true });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,16 +6,26 @@ import {
|
|||||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
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 {
|
export interface SessionBindingsRuntimeDeps {
|
||||||
configDir: string;
|
configDir: string;
|
||||||
getKeybindings: () => Keybinding[];
|
getKeybindings: () => Keybinding[];
|
||||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||||
getResolvedConfig: () => ResolvedConfig;
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
getMpvClient: () =>
|
||||||
|
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
|
||||||
|
| null;
|
||||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||||
logWarn: (message: string, details?: unknown) => void;
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
|
||||||
|
onWarning?: (warning: SessionBindingWarning) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||||
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||||
) => void;
|
) => void;
|
||||||
refreshCurrentSessionBindings: () => void;
|
refreshCurrentSessionBindings: () => void;
|
||||||
|
refreshMpvSessionBindings: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
|
let sourceBindings: CompiledSessionBinding[] = [];
|
||||||
|
let sourceWarnings: SessionBindingWarning[] = [];
|
||||||
|
let nativeSnapshot: {
|
||||||
|
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||||
|
keys: string[];
|
||||||
|
} | null = null;
|
||||||
|
let pending: {
|
||||||
|
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||||
|
promise: Promise<void>;
|
||||||
|
} | null = null;
|
||||||
|
let publishedSignature: string | null = null;
|
||||||
|
let reportedWarnings = new Set<string>();
|
||||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||||
if (process.platform === 'darwin') return 'darwin';
|
if (process.platform === 'darwin') return 'darwin';
|
||||||
if (process.platform === 'win32') return 'win32';
|
if (process.platform === 'win32') return 'win32';
|
||||||
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
bindings: CompiledSessionBinding[],
|
bindings: CompiledSessionBinding[],
|
||||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||||
): void {
|
): 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({
|
const artifact = buildPluginSessionBindingsArtifact({
|
||||||
bindings,
|
bindings: result.bindings,
|
||||||
warnings,
|
warnings,
|
||||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
});
|
});
|
||||||
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
deps.setSessionBindings(bindings);
|
publishedSignature = signature;
|
||||||
|
deps.setSessionBindings(result.bindings);
|
||||||
deps.setSessionBindingsInitialized(true);
|
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();
|
const mpvClient = deps.getMpvClient();
|
||||||
if (mpvClient?.connected) {
|
if (mpvClient?.connected) {
|
||||||
try {
|
try {
|
||||||
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
|||||||
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deps.onBindingsChanged?.(result.bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMpvSessionBindings(): Promise<void> {
|
||||||
|
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 {
|
function refreshCurrentSessionBindings(): void {
|
||||||
const compiled = compileCurrentSessionBindings();
|
const compiled = compileCurrentSessionBindings();
|
||||||
for (const warning of compiled.warnings) {
|
|
||||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
|
||||||
}
|
|
||||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||||
|
void refreshMpvSessionBindings();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>([
|
||||||
|
['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/);
|
||||||
|
});
|
||||||
@@ -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<unknown>;
|
||||||
|
request: (command: unknown[]) => Promise<{ error?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSubtitleSelectionModal(
|
||||||
|
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<SubtitleSelectionState> {
|
||||||
|
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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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<IpcMain, 'handle'>;
|
||||||
|
isAllowedSender: (sender: WebContents) => boolean;
|
||||||
|
runtime: ReturnType<typeof createSubtitleSelectionRuntime>;
|
||||||
|
}): 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
|
|||||||
import type { SubtitleGenerationProgress } from './shared/subtitle-generation';
|
import type { SubtitleGenerationProgress } from './shared/subtitle-generation';
|
||||||
|
|
||||||
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
||||||
|
const onSubtitleSelectionOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleSelectionOpen);
|
||||||
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
|
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
|
||||||
|
|
||||||
type EmptyListener = () => void;
|
type EmptyListener = () => void;
|
||||||
@@ -463,6 +464,11 @@ const electronAPI: ElectronAPI = {
|
|||||||
) as Promise<boolean>,
|
) as Promise<boolean>,
|
||||||
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
|
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,
|
onSubsyncManualOpen: onSubsyncManualOpenEvent,
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
|
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
||||||
@@ -580,6 +586,14 @@ const electronAPI: ElectronAPI = {
|
|||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
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) => {
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
IPC_CHANNELS.event.configHotReload,
|
IPC_CHANNELS.event.configHotReload,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ type ControllerInteractionModalState = {
|
|||||||
jimakuModalOpen: boolean;
|
jimakuModalOpen: boolean;
|
||||||
kikuModalOpen: boolean;
|
kikuModalOpen: boolean;
|
||||||
runtimeOptionsModalOpen: boolean;
|
runtimeOptionsModalOpen: boolean;
|
||||||
|
subtitleSelectionModalOpen?: boolean;
|
||||||
subsyncModalOpen: boolean;
|
subsyncModalOpen: boolean;
|
||||||
subtitleGenerationModalOpen?: boolean;
|
subtitleGenerationModalOpen?: boolean;
|
||||||
youtubePickerModalOpen: boolean;
|
youtubePickerModalOpen: boolean;
|
||||||
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
|
|||||||
state.jimakuModalOpen ||
|
state.jimakuModalOpen ||
|
||||||
state.kikuModalOpen ||
|
state.kikuModalOpen ||
|
||||||
state.runtimeOptionsModalOpen ||
|
state.runtimeOptionsModalOpen ||
|
||||||
|
state.subtitleSelectionModalOpen ||
|
||||||
state.subsyncModalOpen ||
|
state.subsyncModalOpen ||
|
||||||
Boolean(state.subtitleGenerationModalOpen) ||
|
Boolean(state.subtitleGenerationModalOpen) ||
|
||||||
state.youtubePickerModalOpen ||
|
state.youtubePickerModalOpen ||
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
|||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openTsukihime: null,
|
openTsukihime: null,
|
||||||
|
openSubtitleSelection: null,
|
||||||
openSubtitleGeneration: null,
|
openSubtitleGeneration: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
@@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async (
|
|||||||
testGlobals.restore();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createKeyboardHandlers(
|
|||||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean;
|
||||||
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
|
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
|
||||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -133,7 +134,10 @@ export function createKeyboardHandlers(
|
|||||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingSequence: { prefix: string; expires: number } | null = null;
|
||||||
|
|
||||||
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||||
|
pendingSequence = null;
|
||||||
ctx.state.sessionBindings = bindings;
|
ctx.state.sessionBindings = bindings;
|
||||||
ctx.state.sessionBindingMap = new Map(
|
ctx.state.sessionBindingMap = new Map(
|
||||||
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
||||||
@@ -1049,7 +1053,10 @@ export function createKeyboardHandlers(
|
|||||||
window.addEventListener('focus', () => {
|
window.addEventListener('focus', () => {
|
||||||
void importedMpvBindings.refresh();
|
void importedMpvBindings.refresh();
|
||||||
});
|
});
|
||||||
window.addEventListener('blur', importedMpvBindings.releaseAll);
|
window.addEventListener('blur', () => {
|
||||||
|
pendingSequence = null;
|
||||||
|
importedMpvBindings.releaseAll();
|
||||||
|
});
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
clearTimeout(lateScriptRefresh);
|
clearTimeout(lateScriptRefresh);
|
||||||
importedMpvBindings.dispose();
|
importedMpvBindings.dispose();
|
||||||
@@ -1103,6 +1110,13 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
const sequence = pendingSequence;
|
||||||
|
pendingSequence = null;
|
||||||
|
if (ctx.state.subtitleSelectionModalOpen) {
|
||||||
|
pendingSequence = null;
|
||||||
|
options.handleSubtitleSelectionKeydown?.(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ctx.state.subtitleGenerationModalOpen) {
|
if (ctx.state.subtitleGenerationModalOpen) {
|
||||||
options.handleSubtitleGenerationKeydown?.(e);
|
options.handleSubtitleGenerationKeydown?.(e);
|
||||||
return;
|
return;
|
||||||
@@ -1187,6 +1201,7 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTextEntryTarget(e.target)) {
|
if (isTextEntryTarget(e.target)) {
|
||||||
|
pendingSequence = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,6 +1209,16 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
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)) {
|
if (isStatsOverlayToggle(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.electronAPI.toggleStatsOverlay();
|
window.electronAPI.toggleStatsOverlay();
|
||||||
@@ -1276,6 +1301,16 @@ export function createKeyboardHandlers(
|
|||||||
dispatchSessionBinding(binding);
|
dispatchSessionBinding(binding);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!e.repeat &&
|
||||||
|
ctx.state.sessionBindings.some((binding) =>
|
||||||
|
keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 };
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
keyboardConfigLoaded &&
|
keyboardConfigLoaded &&
|
||||||
!ctx.state.playlistBrowserModalOpen &&
|
!ctx.state.playlistBrowserModalOpen &&
|
||||||
|
|||||||
@@ -833,6 +833,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtitleSelectionModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
class="modal-content subsync-modal-content"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="subtitleSelectionTitle"
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="subtitleSelectionTitle">Select subtitles</h2>
|
||||||
|
<button id="subtitleSelectionClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="subsync-form">
|
||||||
|
<label class="subsync-field">
|
||||||
|
<span>Primary subtitle</span>
|
||||||
|
<select id="subtitleSelectionPrimary"></select>
|
||||||
|
</label>
|
||||||
|
<label class="subsync-field">
|
||||||
|
<span>Secondary subtitle</span>
|
||||||
|
<select id="subtitleSelectionSecondary"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="subtitleSelectionStatus"
|
||||||
|
class="runtime-options-status"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
<div class="subsync-footer">
|
||||||
|
<button id="subtitleSelectionApply" class="kiku-confirm-button" type="button">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content subsync-modal-content">
|
<div class="modal-content subsync-modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ function describeSessionAction(
|
|||||||
return 'Open jimaku';
|
return 'Open jimaku';
|
||||||
case 'openTsukihime':
|
case 'openTsukihime':
|
||||||
return 'Open TsukiHime';
|
return 'Open TsukiHime';
|
||||||
|
case 'openSubtitleSelection':
|
||||||
|
return 'Select subtitle tracks';
|
||||||
case 'openSubtitleGeneration':
|
case 'openSubtitleGeneration':
|
||||||
return 'Generate Japanese subtitles';
|
return 'Generate Japanese subtitles';
|
||||||
case 'openYoutubePicker':
|
case 'openYoutubePicker':
|
||||||
@@ -268,6 +270,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
|||||||
case 'openJimaku':
|
case 'openJimaku':
|
||||||
case 'openTsukihime':
|
case 'openTsukihime':
|
||||||
case 'openCharacterDictionaryManager':
|
case 'openCharacterDictionaryManager':
|
||||||
|
case 'openSubtitleSelection':
|
||||||
case 'openSubtitleGeneration':
|
case 'openSubtitleGeneration':
|
||||||
case 'openControllerSelect':
|
case 'openControllerSelect':
|
||||||
case 'openControllerDebug':
|
case 'openControllerDebug':
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { SubtitleSelectionState } from '../../shared/subtitle-selection';
|
||||||
|
import type { RendererContext } from '../context';
|
||||||
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
|
||||||
|
import { createModalFocusGuard } from './modal-focus-guard';
|
||||||
|
|
||||||
|
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (!(node instanceof constructor)) throw new Error(`Missing subtitle selection element: ${id}`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubtitleSelectionModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: { syncSettingsModalSubtitleSuppression: () => void },
|
||||||
|
) {
|
||||||
|
const dom = {
|
||||||
|
modal: element('subtitleSelectionModal', HTMLDivElement),
|
||||||
|
primary: element('subtitleSelectionPrimary', HTMLSelectElement),
|
||||||
|
secondary: element('subtitleSelectionSecondary', HTMLSelectElement),
|
||||||
|
status: element('subtitleSelectionStatus', HTMLDivElement),
|
||||||
|
apply: element('subtitleSelectionApply', HTMLButtonElement),
|
||||||
|
close: element('subtitleSelectionClose', HTMLButtonElement),
|
||||||
|
};
|
||||||
|
let snapshot: SubtitleSelectionState | null = null;
|
||||||
|
let generation = 0;
|
||||||
|
let pending = false;
|
||||||
|
let priorFocus: Element | null = null;
|
||||||
|
const focus = createModalFocusGuard({
|
||||||
|
isOpen: () => ctx.state.subtitleSelectionModalOpen,
|
||||||
|
getModalRoot: () => dom.modal,
|
||||||
|
getPreferredFocusTargets: () => [dom.primary, dom.secondary, dom.apply],
|
||||||
|
getFallbackFocusTarget: () => dom.close,
|
||||||
|
isModalLayer: ctx.platform.isModalLayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
function status(message: string, error = false): void {
|
||||||
|
dom.status.textContent = message;
|
||||||
|
dom.status.classList.toggle('error', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControls(): void {
|
||||||
|
const disabled = pending || !snapshot;
|
||||||
|
dom.primary.disabled = disabled;
|
||||||
|
dom.secondary.disabled = disabled;
|
||||||
|
const duplicate = dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value;
|
||||||
|
dom.apply.disabled = disabled || duplicate;
|
||||||
|
for (const option of dom.secondary.options)
|
||||||
|
option.disabled = option.value !== 'no' && option.value === dom.primary.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populate(select: HTMLSelectElement, selected: number | null): void {
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const track of [{ id: null, label: 'None' }, ...(snapshot?.tracks ?? [])]) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = track.id === null ? 'no' : String(track.id);
|
||||||
|
option.textContent = track.label;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
select.value = selected === null ? 'no' : String(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(openGeneration: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const next = await window.electronAPI.getSubtitleSelection();
|
||||||
|
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
|
||||||
|
snapshot = next;
|
||||||
|
populate(dom.primary, next.primary);
|
||||||
|
populate(dom.secondary, next.secondary);
|
||||||
|
status(
|
||||||
|
next.tracks.length
|
||||||
|
? 'Choose subtitle tracks, then apply.'
|
||||||
|
: 'No subtitle tracks loaded in this video.',
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
|
||||||
|
status(cause instanceof Error ? cause.message : 'Could not read subtitle tracks.', true);
|
||||||
|
} finally {
|
||||||
|
if (generation === openGeneration && ctx.state.subtitleSelectionModalOpen) {
|
||||||
|
pending = false;
|
||||||
|
updateControls();
|
||||||
|
focus.focusFallbackTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(): void {
|
||||||
|
if (ctx.state.subtitleSelectionModalOpen) return;
|
||||||
|
priorFocus = document.activeElement;
|
||||||
|
snapshot = null;
|
||||||
|
pending = true;
|
||||||
|
generation += 1;
|
||||||
|
populate(dom.primary, null);
|
||||||
|
populate(dom.secondary, null);
|
||||||
|
status('Loading subtitle tracks...');
|
||||||
|
updateControls();
|
||||||
|
ctx.state.subtitleSelectionModalOpen = true;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
dom.modal.classList.remove('hidden');
|
||||||
|
dom.modal.setAttribute('aria-hidden', 'false');
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
focus.attach();
|
||||||
|
focus.focusFallbackTarget();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('subtitle-selection');
|
||||||
|
void refresh(generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
if (!ctx.state.subtitleSelectionModalOpen) return;
|
||||||
|
generation += 1;
|
||||||
|
ctx.state.subtitleSelectionModalOpen = false;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
dom.modal.classList.add('hidden');
|
||||||
|
dom.modal.setAttribute('aria-hidden', 'true');
|
||||||
|
focus.detach();
|
||||||
|
window.electronAPI.notifyOverlayModalClosed('subtitle-selection');
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
if (priorFocus instanceof HTMLElement) priorFocus.focus({ preventScroll: true });
|
||||||
|
priorFocus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(): Promise<void> {
|
||||||
|
if (dom.apply.disabled || pending || !snapshot) return;
|
||||||
|
const openGeneration = generation;
|
||||||
|
pending = true;
|
||||||
|
updateControls();
|
||||||
|
status('Applying subtitle tracks...');
|
||||||
|
try {
|
||||||
|
await window.electronAPI.applySubtitleSelection({
|
||||||
|
mediaPath: snapshot.mediaPath,
|
||||||
|
primary: dom.primary.value === 'no' ? null : Number(dom.primary.value),
|
||||||
|
secondary: dom.secondary.value === 'no' ? null : Number(dom.secondary.value),
|
||||||
|
});
|
||||||
|
if (generation === openGeneration) close();
|
||||||
|
} catch (cause) {
|
||||||
|
if (generation === openGeneration)
|
||||||
|
status(cause instanceof Error ? cause.message : 'Could not select subtitle tracks.', true);
|
||||||
|
} finally {
|
||||||
|
if (generation === openGeneration) {
|
||||||
|
pending = false;
|
||||||
|
updateControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): boolean {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
} else if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
!(event.target instanceof HTMLSelectElement) &&
|
||||||
|
event.target !== dom.close
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
void apply();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
dom.close.addEventListener('click', close);
|
||||||
|
dom.apply.addEventListener('click', () => void apply());
|
||||||
|
dom.primary.addEventListener('change', () => {
|
||||||
|
if (dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value)
|
||||||
|
dom.secondary.value = 'no';
|
||||||
|
updateControls();
|
||||||
|
});
|
||||||
|
dom.secondary.addEventListener('change', updateControls);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { open, close, handleKeydown, wireDomEvents, dispose: () => focus.detach() };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
|||||||
state.youtubePickerModalOpen ||
|
state.youtubePickerModalOpen ||
|
||||||
state.kikuModalOpen ||
|
state.kikuModalOpen ||
|
||||||
state.runtimeOptionsModalOpen ||
|
state.runtimeOptionsModalOpen ||
|
||||||
|
state.subtitleSelectionModalOpen ||
|
||||||
state.subsyncModalOpen ||
|
state.subsyncModalOpen ||
|
||||||
state.subtitleGenerationModalOpen ||
|
state.subtitleGenerationModalOpen ||
|
||||||
state.sessionHelpModalOpen,
|
state.sessionHelpModalOpen,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selectio
|
|||||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||||
|
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
|
||||||
import { createSubsyncModal } from './modals/subsync.js';
|
import { createSubsyncModal } from './modals/subsync.js';
|
||||||
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
||||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||||
@@ -153,6 +154,12 @@ const modalDescriptors = [
|
|||||||
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
|
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
|
||||||
suppressesSubtitles: true,
|
suppressesSubtitles: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle-selection',
|
||||||
|
isOpen: () => ctx.state.subtitleSelectionModalOpen,
|
||||||
|
close: () => subtitleSelectionModal.close(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'subsync',
|
id: 'subsync',
|
||||||
isOpen: () => ctx.state.subsyncModalOpen,
|
isOpen: () => ctx.state.subsyncModalOpen,
|
||||||
@@ -216,6 +223,9 @@ const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
const subsyncModal = createSubsyncModal(ctx, {
|
const subsyncModal = createSubsyncModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
@@ -293,6 +303,7 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
|||||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||||
|
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
|
||||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||||
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
||||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||||
@@ -624,6 +635,9 @@ function registerModalOpenHandlers(): void {
|
|||||||
youtubePickerModal.closeYoutubePickerModal();
|
youtubePickerModal.closeYoutubePickerModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onSubtitleSelectionOpen(() => {
|
||||||
|
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
|
||||||
|
});
|
||||||
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||||
runGuarded('subsync:manual-open', () => {
|
runGuarded('subsync:manual-open', () => {
|
||||||
subsyncModal.openSubsyncModal(payload);
|
subsyncModal.openSubsyncModal(payload);
|
||||||
@@ -849,6 +863,7 @@ async function init(): Promise<void> {
|
|||||||
playlistBrowserModal.wireDomEvents();
|
playlistBrowserModal.wireDomEvents();
|
||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
|
subtitleSelectionModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
subtitleGenerationModal.wireDomEvents();
|
subtitleGenerationModal.wireDomEvents();
|
||||||
controllerSelectModal.wireDomEvents();
|
controllerSelectModal.wireDomEvents();
|
||||||
@@ -858,6 +873,7 @@ async function init(): Promise<void> {
|
|||||||
subtitleSidebarModal.wireDomEvents();
|
subtitleSidebarModal.wireDomEvents();
|
||||||
characterDictionaryModal.wireDomEvents();
|
characterDictionaryModal.wireDomEvents();
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
|
subtitleSelectionModal.dispose();
|
||||||
subtitleGenerationModal.dispose();
|
subtitleGenerationModal.dispose();
|
||||||
subtitleSidebarModal.disposeDomEvents();
|
subtitleSidebarModal.disposeDomEvents();
|
||||||
});
|
});
|
||||||
@@ -867,9 +883,13 @@ async function init(): Promise<void> {
|
|||||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
|
||||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||||
runGuarded('config:hot-reload', () => {
|
runGuarded('config:hot-reload', () => {
|
||||||
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
|
void window.electronAPI
|
||||||
|
.getSessionBindings()
|
||||||
|
.then(keyboardHandlers.updateSessionBindings)
|
||||||
|
.catch((error: unknown) => console.error('Could not refresh session bindings', error));
|
||||||
void keyboardHandlers.refreshConfiguredShortcuts();
|
void keyboardHandlers.refreshConfiguredShortcuts();
|
||||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||||
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
|
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export type RendererState = {
|
|||||||
characterDictionaryStatus: string;
|
characterDictionaryStatus: string;
|
||||||
|
|
||||||
subsyncModalOpen: boolean;
|
subsyncModalOpen: boolean;
|
||||||
|
subtitleSelectionModalOpen: boolean;
|
||||||
subtitleGenerationModalOpen: boolean;
|
subtitleGenerationModalOpen: boolean;
|
||||||
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||||
subsyncSubmitting: boolean;
|
subsyncSubmitting: boolean;
|
||||||
@@ -223,6 +224,7 @@ export function createRendererState(): RendererState {
|
|||||||
characterDictionaryStatus: '',
|
characterDictionaryStatus: '',
|
||||||
|
|
||||||
subsyncModalOpen: false,
|
subsyncModalOpen: false,
|
||||||
|
subtitleSelectionModalOpen: false,
|
||||||
subtitleGenerationModalOpen: false,
|
subtitleGenerationModalOpen: false,
|
||||||
subsyncSubtitleTracks: [],
|
subsyncSubtitleTracks: [],
|
||||||
subsyncSubmitting: false,
|
subsyncSubmitting: false,
|
||||||
|
|||||||
@@ -3228,6 +3228,16 @@ iframe[id^='yomitan-popup'],
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subsync-field select:focus-visible {
|
||||||
|
outline: 2px solid var(--ctp-blue);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subtitleSelectionModal .kiku-confirm-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.subsync-footer {
|
.subsync-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
|||||||
export const OVERLAY_HOSTED_MODALS = [
|
export const OVERLAY_HOSTED_MODALS = [
|
||||||
'runtime-options',
|
'runtime-options',
|
||||||
'subsync',
|
'subsync',
|
||||||
|
'subtitle-selection',
|
||||||
'subtitle-generation',
|
'subtitle-generation',
|
||||||
'jimaku',
|
'jimaku',
|
||||||
'tsukihime',
|
'tsukihime',
|
||||||
@@ -51,6 +52,8 @@ export const IPC_CHANNELS = {
|
|||||||
dispatchSessionAction: 'session-action:dispatch',
|
dispatchSessionAction: 'session-action:dispatch',
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
|
getSubtitleSelection: 'subtitle-selection:get',
|
||||||
|
applySubtitleSelection: 'subtitle-selection:apply',
|
||||||
requestSubtitleGenerationOpen: 'subtitle-generation:open',
|
requestSubtitleGenerationOpen: 'subtitle-generation:open',
|
||||||
getSubtitleGenerationStatus: 'subtitle-generation:status',
|
getSubtitleGenerationStatus: 'subtitle-generation:status',
|
||||||
startSubtitleGeneration: 'subtitle-generation:start',
|
startSubtitleGeneration: 'subtitle-generation:start',
|
||||||
@@ -143,6 +146,7 @@ export const IPC_CHANNELS = {
|
|||||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
|
subtitleSelectionOpen: 'subtitle-selection:opened',
|
||||||
subtitleGenerationOpen: 'subtitle-generation:opened',
|
subtitleGenerationOpen: 'subtitle-generation:opened',
|
||||||
subtitleGenerationProgress: 'subtitle-generation:progress',
|
subtitleGenerationProgress: 'subtitle-generation:progress',
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
@@ -174,6 +178,7 @@ export const IPC_CHANNELS = {
|
|||||||
controllerDebugOpen: 'controller-debug:open',
|
controllerDebugOpen: 'controller-debug:open',
|
||||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||||
|
sessionBindingsChanged: 'session-bindings:changed',
|
||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
overlayNotification: 'overlay:notification',
|
overlayNotification: 'overlay:notification',
|
||||||
notificationHistoryToggle: 'notification-history:toggle',
|
notificationHistoryToggle: 'notification-history:toggle',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
|||||||
'openControllerDebug',
|
'openControllerDebug',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
'openTsukihime',
|
'openTsukihime',
|
||||||
|
'openSubtitleSelection',
|
||||||
'openSubtitleGeneration',
|
'openSubtitleGeneration',
|
||||||
'openYoutubePicker',
|
'openYoutubePicker',
|
||||||
'openPlaylistBrowser',
|
'openPlaylistBrowser',
|
||||||
|
|||||||
@@ -103,3 +103,13 @@ test('SubMiner ownership recognizes leading mpv prefixes without matching comman
|
|||||||
['d', 'e', 'f', 'g'],
|
['d', 'e', 'f', 'g'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sequence conflict discovery allows winning ignore bindings used by mpv sequence prefixes', () => {
|
||||||
|
const bindings = [
|
||||||
|
{ key: 'g', cmd: 'show-text old', priority: 0 },
|
||||||
|
{ key: 'g', cmd: 'no-osd ignore', priority: 1 },
|
||||||
|
{ key: 'h', cmd: 'show-text action', priority: 0 },
|
||||||
|
];
|
||||||
|
assert.deepEqual(parseMpvInputBindingKeys(bindings), ['g', 'h']);
|
||||||
|
assert.deepEqual(parseMpvInputBindingKeys(bindings, { includeIgnored: false }), ['h']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,9 +59,12 @@ export function keyboardEventToMpvKey(
|
|||||||
return normalizeMpvInputKey([...modifiers, key].join('+'));
|
return normalizeMpvInputKey([...modifiers, key].join('+'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMpvInputBindingKeys(value: unknown): string[] {
|
export function parseMpvInputBindingKeys(
|
||||||
|
value: unknown,
|
||||||
|
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
|
||||||
|
): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
const bindings = new Map<string, { priority: number; owned: boolean }>();
|
const bindings = new Map<string, { priority: number; owned: boolean; ignored: boolean }>();
|
||||||
for (const candidate of value) {
|
for (const candidate of value) {
|
||||||
const entry: unknown = candidate;
|
const entry: unknown = candidate;
|
||||||
if (
|
if (
|
||||||
@@ -94,8 +97,14 @@ export function parseMpvInputBindingKeys(value: unknown): string[] {
|
|||||||
entry.priority > previous.priority ||
|
entry.priority > previous.priority ||
|
||||||
(entry.priority === previous.priority && owned)
|
(entry.priority === previous.priority && owned)
|
||||||
) {
|
) {
|
||||||
bindings.set(key, { priority: entry.priority, owned });
|
bindings.set(key, {
|
||||||
|
priority: entry.priority,
|
||||||
|
owned,
|
||||||
|
ignored: entry.cmd.trim().replace(MPV_COMMAND_PREFIXES, '') === 'ignore',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key);
|
return [...bindings]
|
||||||
|
.filter(([, binding]) => !binding.owned && (includeIgnored || !binding.ignored))
|
||||||
|
.map(([key]) => key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type {
|
||||||
|
CompiledSessionBinding,
|
||||||
|
SessionBindingWarning,
|
||||||
|
SessionKeySpec,
|
||||||
|
} from '../types/session-bindings';
|
||||||
|
|
||||||
|
export interface SessionKeyReservation {
|
||||||
|
key: SessionKeySpec;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionSequencePrefix(key: SessionKeySpec): SessionKeySpec | null {
|
||||||
|
const match = /^(Key[A-Z])-Key[A-Z]$/.exec(key.code);
|
||||||
|
return match?.[1] ? { code: match[1], modifiers: key.modifiers } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signature(key: SessionKeySpec): string {
|
||||||
|
return [...key.modifiers, key.code].join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSessionSequenceConflicts(
|
||||||
|
bindings: CompiledSessionBinding[],
|
||||||
|
reservations: SessionKeyReservation[] = [],
|
||||||
|
): { bindings: CompiledSessionBinding[]; warnings: SessionBindingWarning[] } {
|
||||||
|
const singles = new Map<string, string[]>();
|
||||||
|
for (const { key, path } of [
|
||||||
|
...bindings.map((binding) => ({ key: binding.key, path: binding.sourcePath })),
|
||||||
|
...reservations,
|
||||||
|
]) {
|
||||||
|
if (getSessionSequencePrefix(key)) continue;
|
||||||
|
const id = signature(key);
|
||||||
|
singles.set(id, [...(singles.get(id) ?? []), path]);
|
||||||
|
}
|
||||||
|
const warnings: SessionBindingWarning[] = [];
|
||||||
|
const effective = bindings.filter((binding) => {
|
||||||
|
const prefix = getSessionSequencePrefix(binding.key);
|
||||||
|
if (!prefix) return true;
|
||||||
|
const conflicts = singles.get(signature(prefix));
|
||||||
|
if (!conflicts?.length) return true;
|
||||||
|
const paths = [...new Set(conflicts)];
|
||||||
|
warnings.push({
|
||||||
|
kind: 'conflict',
|
||||||
|
path: binding.sourcePath,
|
||||||
|
value: binding.originalKey,
|
||||||
|
conflictingPaths: paths,
|
||||||
|
message: `Disabled sequence "${binding.originalKey}" (${binding.sourcePath}): its first key is reserved by ${paths.join(', ')}. Single-key bindings take priority; remap the sequence or its conflicting binding.`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return { bindings: effective, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imported mpv keys preserve case: g and G are different strokes.
|
||||||
|
export function reserveMpvSequencePrefixes(keys: string[]): SessionKeyReservation[] {
|
||||||
|
return keys.flatMap((value) => {
|
||||||
|
const parts = value.split('+');
|
||||||
|
const letter = parts.pop();
|
||||||
|
if (!letter || !/^[a-z]$/i.test(letter)) return [];
|
||||||
|
const modifiers: SessionKeySpec['modifiers'] = [];
|
||||||
|
if (parts.includes('ctrl')) modifiers.push('ctrl');
|
||||||
|
if (parts.includes('alt')) modifiers.push('alt');
|
||||||
|
if (parts.includes('shift') || /^[A-Z]$/.test(letter)) modifiers.push('shift');
|
||||||
|
if (parts.includes('meta')) modifiers.push('meta');
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: { code: `Key${letter.toUpperCase()}`, modifiers },
|
||||||
|
path: `mpv input binding "${value}"`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface SubtitleSelectionState {
|
||||||
|
mediaPath: string;
|
||||||
|
tracks: { id: number; label: string }[];
|
||||||
|
primary: number | null;
|
||||||
|
secondary: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleSelectionRequest = Pick<
|
||||||
|
SubtitleSelectionState,
|
||||||
|
'mediaPath' | 'primary' | 'secondary'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function parseSubtitleSelectionRequest(value: unknown): SubtitleSelectionRequest {
|
||||||
|
if (
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
value === null ||
|
||||||
|
!('mediaPath' in value) ||
|
||||||
|
typeof value.mediaPath !== 'string' ||
|
||||||
|
!value.mediaPath ||
|
||||||
|
!('primary' in value) ||
|
||||||
|
!isTrackSelection(value.primary) ||
|
||||||
|
!('secondary' in value) ||
|
||||||
|
!isTrackSelection(value.secondary)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid subtitle selection.');
|
||||||
|
if (value.primary !== null && value.primary === value.secondary)
|
||||||
|
throw new Error('Choose different primary and secondary tracks.');
|
||||||
|
return { mediaPath: value.mediaPath, primary: value.primary, secondary: value.secondary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackSelection(value: unknown): value is number | null {
|
||||||
|
return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ export interface ShortcutsConfig {
|
|||||||
openRuntimeOptions?: string | null;
|
openRuntimeOptions?: string | null;
|
||||||
openJimaku?: string | null;
|
openJimaku?: string | null;
|
||||||
openTsukihime?: string | null;
|
openTsukihime?: string | null;
|
||||||
|
openSubtitleSelection?: string | null;
|
||||||
openSubtitleGeneration?: string | null;
|
openSubtitleGeneration?: string | null;
|
||||||
openSessionHelp?: string | null;
|
openSessionHelp?: string | null;
|
||||||
openControllerSelect?: string | null;
|
openControllerSelect?: string | null;
|
||||||
@@ -152,6 +153,7 @@ export interface Config {
|
|||||||
shortcuts?: RawShortcutsConfig;
|
shortcuts?: RawShortcutsConfig;
|
||||||
secondarySub?: SecondarySubConfig;
|
secondarySub?: SecondarySubConfig;
|
||||||
subsync?: SubsyncConfig;
|
subsync?: SubsyncConfig;
|
||||||
|
subtitleSelection?: { enabled?: boolean };
|
||||||
subtitleGeneration?: Partial<SubtitleGenerationConfig>;
|
subtitleGeneration?: Partial<SubtitleGenerationConfig>;
|
||||||
startupWarmups?: StartupWarmupsConfig;
|
startupWarmups?: StartupWarmupsConfig;
|
||||||
subtitleStyle?: SubtitleStyleConfig;
|
subtitleStyle?: SubtitleStyleConfig;
|
||||||
@@ -304,6 +306,7 @@ export interface ResolvedConfig {
|
|||||||
shortcuts: Required<ShortcutsConfig>;
|
shortcuts: Required<ShortcutsConfig>;
|
||||||
secondarySub: Required<SecondarySubConfig>;
|
secondarySub: Required<SecondarySubConfig>;
|
||||||
subsync: Required<SubsyncConfig>;
|
subsync: Required<SubsyncConfig>;
|
||||||
|
subtitleSelection: { enabled: boolean };
|
||||||
subtitleGeneration: SubtitleGenerationConfig;
|
subtitleGeneration: SubtitleGenerationConfig;
|
||||||
startupWarmups: {
|
startupWarmups: {
|
||||||
lowPowerMode: boolean;
|
lowPowerMode: boolean;
|
||||||
|
|||||||
@@ -525,6 +525,13 @@ export interface ElectronAPI {
|
|||||||
focusMainWindow: () => Promise<void>;
|
focusMainWindow: () => Promise<void>;
|
||||||
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
|
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
|
||||||
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
|
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
|
||||||
|
onSubtitleSelectionOpen: (callback: () => void) => void;
|
||||||
|
getSubtitleSelection: () => Promise<
|
||||||
|
import('../shared/subtitle-selection').SubtitleSelectionState
|
||||||
|
>;
|
||||||
|
applySubtitleSelection: (
|
||||||
|
request: import('../shared/subtitle-selection').SubtitleSelectionRequest,
|
||||||
|
) => Promise<void>;
|
||||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
||||||
@@ -604,6 +611,7 @@ export interface ElectronAPI {
|
|||||||
modal:
|
modal:
|
||||||
| 'runtime-options'
|
| 'runtime-options'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
|
| 'subtitle-selection'
|
||||||
| 'subtitle-generation'
|
| 'subtitle-generation'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'tsukihime'
|
| 'tsukihime'
|
||||||
@@ -622,6 +630,7 @@ export interface ElectronAPI {
|
|||||||
modal:
|
modal:
|
||||||
| 'runtime-options'
|
| 'runtime-options'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
|
| 'subtitle-selection'
|
||||||
| 'subtitle-generation'
|
| 'subtitle-generation'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'tsukihime'
|
| 'tsukihime'
|
||||||
@@ -637,6 +646,7 @@ export interface ElectronAPI {
|
|||||||
| 'changelog',
|
| 'changelog',
|
||||||
) => void;
|
) => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
|
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type SessionActionId =
|
|||||||
| 'openControllerDebug'
|
| 'openControllerDebug'
|
||||||
| 'openJimaku'
|
| 'openJimaku'
|
||||||
| 'openTsukihime'
|
| 'openTsukihime'
|
||||||
|
| 'openSubtitleSelection'
|
||||||
| 'openSubtitleGeneration'
|
| 'openSubtitleGeneration'
|
||||||
| 'openYoutubePicker'
|
| 'openYoutubePicker'
|
||||||
| 'openPlaylistBrowser'
|
| 'openPlaylistBrowser'
|
||||||
|
|||||||
Reference in New Issue
Block a user