mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
feat(overlay): add optional subtitle selection modal
- Add primary and secondary mpv track selection from the overlay - Support configurable sequence shortcuts with conflict detection and hot reload - Document settings, shortcuts, and generated config defaults
This commit is contained in:
@@ -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
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
@@ -223,6 +232,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
|
||||
@@ -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>
|
||||
|
||||
## Subtitle Selection
|
||||
|
||||
Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals.
|
||||
|
||||
Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available.
|
||||
|
||||
`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults.
|
||||
|
||||
## External integrations
|
||||
|
||||
### Jimaku
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
// Hot-reload: enabling or disabling updates the session shortcut immediately.
|
||||
// ==========================================
|
||||
"subtitleSelection": {
|
||||
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
|
||||
}, // Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
@@ -223,6 +232,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
|
||||
@@ -88,6 +88,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
|
||||
@@ -98,6 +99,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
|
||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||
|
||||
Enable the subtitle selector in **Settings → Behavior → Subtitle Selection**. Its shortcut overrides mpv subtitle selection only while enabled. In the focused overlay, press the second key within one second. Single-key bindings take priority: if `g` already has an action in SubMiner or mpv, `g-s` is disabled with a conflict warning, and `g` still runs immediately. Remap the sequence or remove the conflicting single-key binding. The existing `y` prefix is reserved for its built-in commands. mpv bindings are checked on connection, configuration changes, and overlay focus; refresh the overlay after changing another script's bindings. See [subtitle selection](/configuration#subtitle-selection).
|
||||
|
||||
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||
|
||||
In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
|
||||
|
||||
@@ -35,6 +35,8 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
@@ -91,6 +91,10 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function key_code_to_mpv_name(code)
|
||||
local first, second = code:match("^Key([A-Z])%-Key([A-Z])$")
|
||||
if first and second then
|
||||
return string.lower(first) .. "-" .. string.lower(second)
|
||||
end
|
||||
if KEY_NAME_MAP[code] then
|
||||
return KEY_NAME_MAP[code]
|
||||
end
|
||||
@@ -187,6 +191,110 @@ function M.create(ctx)
|
||||
return bindings
|
||||
end
|
||||
|
||||
-- Match letter strokes, including mpv's uppercase spelling for Shift.
|
||||
local function letter_key_signature(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local modifiers = {}
|
||||
while true do
|
||||
local modifier, rest = value:match("^([%a]+)%+(.+)$")
|
||||
if not modifier then
|
||||
break
|
||||
end
|
||||
modifier = string.lower(modifier)
|
||||
if not MODIFIER_MAP[modifier] then
|
||||
return nil
|
||||
end
|
||||
modifiers[modifier] = true
|
||||
value = rest
|
||||
end
|
||||
if not value:match("^[a-zA-Z]$") then
|
||||
return nil
|
||||
end
|
||||
if value:match("^[A-Z]$") then
|
||||
modifiers.shift = true
|
||||
end
|
||||
local parts = {}
|
||||
for _, modifier in ipairs({ "ctrl", "alt", "shift", "meta" }) do
|
||||
if modifiers[modifier] then
|
||||
parts[#parts + 1] = modifier
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = string.lower(value)
|
||||
return table.concat(parts, "+")
|
||||
end
|
||||
|
||||
local function external_single_keys()
|
||||
local keys = {}
|
||||
local native = mp.get_property_native and mp.get_property_native("input-bindings") or {}
|
||||
for _, entry in ipairs(native or {}) do
|
||||
local signature = letter_key_signature(entry.key)
|
||||
if
|
||||
signature
|
||||
and type(entry.cmd) == "string"
|
||||
and type(entry.priority) == "number"
|
||||
and entry.priority >= 0
|
||||
then
|
||||
local owned = entry.owner == "subminer"
|
||||
or (
|
||||
entry.owner == nil
|
||||
and (
|
||||
entry.cmd:match("script%-binding%s+['\"]?subminer/")
|
||||
or entry.cmd:match("script%-message%s+['\"]?subminer%-")
|
||||
)
|
||||
)
|
||||
local previous = keys[signature]
|
||||
if
|
||||
not previous
|
||||
or entry.priority > previous.priority
|
||||
or (entry.priority == previous.priority and owned)
|
||||
then
|
||||
local command = entry.cmd:match("^%s*(.-)%s*$")
|
||||
local flags = {
|
||||
["no-osd"] = true,
|
||||
["osd-bar"] = true,
|
||||
["osd-msg"] = true,
|
||||
["osd-msg-bar"] = true,
|
||||
["osd-auto"] = true,
|
||||
["expand-properties"] = true,
|
||||
["raw"] = true,
|
||||
["repeatable"] = true,
|
||||
["nonrepeatable"] = true,
|
||||
["nonscalable"] = true,
|
||||
["async"] = true,
|
||||
["sync"] = true,
|
||||
}
|
||||
while true do
|
||||
local flag, rest = command:match("^(%S+)%s+(.+)$")
|
||||
if not flags[flag] then
|
||||
break
|
||||
end
|
||||
command = rest
|
||||
end
|
||||
keys[signature] = { priority = entry.priority, owned = owned, ignored = command == "ignore" }
|
||||
end
|
||||
end
|
||||
end
|
||||
return keys
|
||||
end
|
||||
|
||||
local function sequence_conflict(binding, singles)
|
||||
local code = binding.key and binding.key.code
|
||||
local prefix = type(code) == "string" and code:match("^(Key[A-Z])%-Key[A-Z]$")
|
||||
if not prefix then
|
||||
return nil
|
||||
end
|
||||
local names = key_spec_to_mpv_bindings({ code = prefix, modifiers = binding.key.modifiers }) or {}
|
||||
for _, name in ipairs(names) do
|
||||
local existing = singles[letter_key_signature(name)]
|
||||
if existing and not existing.owned and not existing.ignored then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function normalize_cli_args(cli_args)
|
||||
if type(cli_args) ~= "table" then
|
||||
return nil
|
||||
@@ -391,17 +499,23 @@ function M.create(ctx)
|
||||
local next_binding_names = {}
|
||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||
local generation = state.session_binding_generation
|
||||
local singles = external_single_keys()
|
||||
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
if not is_supported_binding(binding) then
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported session binding from artifact"
|
||||
)
|
||||
subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
|
||||
else
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
local conflict = sequence_conflict(binding, singles)
|
||||
if conflict then
|
||||
local message = "Disabled sequence "
|
||||
.. tostring(binding.originalKey or binding.key.code)
|
||||
.. ": mpv already uses "
|
||||
.. conflict
|
||||
.. ". Single-key bindings take priority."
|
||||
subminer_log("warn", "session-bindings", message)
|
||||
show_osd(message)
|
||||
elseif key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
local name = "subminer-session-binding-"
|
||||
.. tostring(generation)
|
||||
@@ -418,7 +532,8 @@ function M.create(ctx)
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||
"Skipped unsupported key code from artifact: "
|
||||
.. tostring(binding.key and binding.key.code or "unknown")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,6 +69,12 @@ local ctx = {
|
||||
return {
|
||||
numericSelectionTimeoutMs = 3000,
|
||||
bindings = {
|
||||
{
|
||||
key = { code = "KeyG-KeyS", modifiers = {} },
|
||||
actionType = "session-action",
|
||||
actionId = "openSubtitleSelection",
|
||||
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyO",
|
||||
@@ -312,7 +318,8 @@ local ctx = {
|
||||
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
nil
|
||||
end,
|
||||
},
|
||||
state = {
|
||||
@@ -430,17 +437,11 @@ assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
|
||||
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
|
||||
|
||||
local character_dictionary_manager = find_binding("Ctrl+d")
|
||||
assert_true(
|
||||
character_dictionary_manager ~= nil,
|
||||
"character dictionary manager binding should be registered"
|
||||
)
|
||||
assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
|
||||
|
||||
character_dictionary_manager.fn()
|
||||
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(
|
||||
character_dictionary_manager_call ~= nil,
|
||||
"character dictionary manager binding should invoke CLI action"
|
||||
)
|
||||
assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
|
||||
assert_true(
|
||||
character_dictionary_manager_call[2] == "--session-action",
|
||||
"character dictionary manager binding should use generic session action CLI flag"
|
||||
@@ -474,3 +475,35 @@ assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine
|
||||
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||
|
||||
print("plugin session binding regression tests: OK")
|
||||
|
||||
local selector = find_binding("g-s")
|
||||
assert_true(selector ~= nil, "subtitle selection should override mpv g-s with a forced sequence")
|
||||
selector.fn()
|
||||
local selection_call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(
|
||||
selection_call[3] == '{"actionId":"openSubtitleSelection"}',
|
||||
"subtitle selection should dispatch its session action"
|
||||
)
|
||||
|
||||
local native_bindings = {}
|
||||
function mp.get_property_native(name)
|
||||
assert_true(name == "input-bindings", "only native input bindings should be queried")
|
||||
return native_bindings
|
||||
end
|
||||
|
||||
for _, case in ipairs({
|
||||
{ key = "g", priority = 1, enabled = false },
|
||||
{ key = "G", priority = 1, enabled = true },
|
||||
{ key = "Shift+g", priority = 1, enabled = true },
|
||||
{ key = "Ctrl+g", priority = 1, enabled = true },
|
||||
{ key = "g", priority = -1, enabled = true },
|
||||
{ key = "g", priority = 1, cmd = "ignore", enabled = true },
|
||||
{ key = "g", priority = 1, cmd = "no-osd ignore", enabled = true },
|
||||
}) do
|
||||
native_bindings = { { key = case.key, cmd = case.cmd or "show-text single", priority = case.priority } }
|
||||
recorded.bindings = {}
|
||||
assert_true(bindings.reload_bindings(), "binding reload should succeed")
|
||||
assert_true((find_binding("g-s") ~= nil) == case.enabled, "sequence prefix conflict: " .. case.key)
|
||||
end
|
||||
assert_true(#recorded.osd > 0, "native prefix conflicts should be visible")
|
||||
print("plugin sequence conflict tests: OK")
|
||||
|
||||
@@ -56,6 +56,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSelection: { enabled: false },
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
|
||||
@@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openTsukihime: 'Ctrl+Shift+T',
|
||||
openSubtitleSelection: 'g-s',
|
||||
openSubtitleGeneration: 'Ctrl+Shift+G',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
|
||||
@@ -628,6 +628,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSubtitleSelection',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openSubtitleSelection,
|
||||
description:
|
||||
'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSubtitleGeneration',
|
||||
kind: 'string',
|
||||
|
||||
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
return [
|
||||
{
|
||||
path: 'subtitleSelection.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleSelection.enabled,
|
||||
description:
|
||||
'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.',
|
||||
},
|
||||
...(
|
||||
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
|
||||
).map((key) => ({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'],
|
||||
key: 'subtitleSelection',
|
||||
},
|
||||
{
|
||||
title: 'Japanese Subtitle Generation',
|
||||
description: [
|
||||
|
||||
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
|
||||
const HOT_RELOAD_ROOTS = [
|
||||
'subtitleStyle',
|
||||
'keybindings',
|
||||
'shortcuts',
|
||||
'subtitleSidebar',
|
||||
'subtitleSelection',
|
||||
] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
|
||||
@@ -6,6 +6,25 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (isObject(src.subtitleSelection)) {
|
||||
const enabled = asBoolean(src.subtitleSelection.enabled);
|
||||
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
|
||||
else if (src.subtitleSelection.enabled !== undefined)
|
||||
warn(
|
||||
'subtitleSelection.enabled',
|
||||
src.subtitleSelection.enabled,
|
||||
resolved.subtitleSelection.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
} else if (src.subtitleSelection !== undefined) {
|
||||
warn(
|
||||
'subtitleSelection',
|
||||
src.subtitleSelection,
|
||||
resolved.subtitleSelection,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
if (launchAtStartup !== undefined) {
|
||||
@@ -237,6 +256,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleSelection',
|
||||
'openSubtitleGeneration',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
|
||||
@@ -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.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subtitleSelection.')) {
|
||||
return { category: 'behavior', section: 'Subtitle Selection' };
|
||||
}
|
||||
if (path.startsWith('subtitleGeneration.')) {
|
||||
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
|
||||
}
|
||||
@@ -631,6 +634,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSubtitleSelection' ||
|
||||
leaf === 'openSubtitleGeneration' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||
getSessionBindings?: () => CompiledSessionBinding[];
|
||||
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||
getSessionBindings?: () => CompiledSessionBinding[];
|
||||
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openSubtitleSelection: () => calls.push('subtitle-selection'),
|
||||
openSubtitleGeneration: () => calls.push('subtitle-generation'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
openSubtitleSelection: () => void;
|
||||
openSubtitleGeneration: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openSubtitleSelection':
|
||||
deps.openSubtitleSelection();
|
||||
return;
|
||||
case 'openSubtitleGeneration':
|
||||
deps.openSubtitleGeneration();
|
||||
return;
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
@@ -707,3 +708,58 @@ test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-co
|
||||
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
|
||||
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
|
||||
});
|
||||
|
||||
test('single keys reserve sequence prefixes without reserving the second stroke', () => {
|
||||
for (const key of ['g', 'Ctrl+g', 's']) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: 'g-s' }),
|
||||
keybindings: [createKeybinding(key, ['show-text', 'single'])],
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result.bindings.some((binding) => binding.originalKey === key));
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === 'g-s'),
|
||||
key !== 'g',
|
||||
);
|
||||
assert.equal(result.warnings.length, key === 'g' ? 1 : 0);
|
||||
if (key === 'g') assert.match(result.warnings[0]!.message, /Single-key bindings take priority/);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured shortcuts and built-in overlay keys also reserve sequence prefixes', () => {
|
||||
for (const prefix of ['g', 'y', 'v']) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: `${prefix}-s`, copySubtitle: 'g' }),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === `${prefix}-s`),
|
||||
false,
|
||||
);
|
||||
assert.ok(result.bindings.some((binding) => binding.originalKey === 'g'));
|
||||
assert.equal(result.warnings.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('sequence reservations follow the sidebar code and literal Shift semantics', () => {
|
||||
for (const [toggleKey, disabled] of [
|
||||
['KeyG', true],
|
||||
['g', false],
|
||||
['G', true],
|
||||
] as const) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: 'Shift+g-s' }),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
rawConfig: {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleSidebar: { ...DEFAULT_CONFIG.subtitleSidebar, toggleKey },
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === 'Shift+g-s'),
|
||||
!disabled,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
SessionKeySpec,
|
||||
} from '../../types/session-bindings';
|
||||
import { SPECIAL_COMMANDS } from '../../config';
|
||||
import {
|
||||
resolveSessionSequenceConflicts,
|
||||
type SessionKeyReservation,
|
||||
} from '../../shared/session-key-sequences';
|
||||
|
||||
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
|
||||
|
||||
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
|
||||
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
@@ -81,6 +86,13 @@ function normalizeCodeToken(
|
||||
): string | null {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) return null;
|
||||
// Two lowercase letters use mpv's sequential-key syntax, for example g-s.
|
||||
if (/^[a-z]-[a-z]$/.test(normalized)) {
|
||||
return normalized
|
||||
.split('-')
|
||||
.map((letter) => `Key${letter.toUpperCase()}`)
|
||||
.join('-');
|
||||
}
|
||||
if (options.allowMouseButtons === true) {
|
||||
const normalizedMouse = normalized.toUpperCase();
|
||||
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
|
||||
@@ -543,7 +555,37 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
|
||||
}
|
||||
|
||||
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
|
||||
return { bindings, warnings };
|
||||
const reservations: SessionKeyReservation[] = [
|
||||
{ key: { code: 'KeyY', modifiers: [] }, path: 'built-in y sequences' },
|
||||
{ key: { code: 'KeyV', modifiers: [] }, path: 'primary subtitle visibility key' },
|
||||
{ key: { code: 'KeyY', modifiers: ['ctrl'] }, path: 'lookup window toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['meta'] }, path: 'lookup window toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['ctrl', 'shift'] }, path: 'keyboard-driven mode toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['shift', 'meta'] }, path: 'keyboard-driven mode toggle' },
|
||||
...[...candidates.values()].flatMap((drafts) =>
|
||||
drafts.map(({ binding }) => ({
|
||||
key: binding.key,
|
||||
path: binding.sourcePath,
|
||||
})),
|
||||
),
|
||||
];
|
||||
const sidebarKey = input.rawConfig?.subtitleSidebar?.toggleKey;
|
||||
if (sidebarKey) {
|
||||
const { key } = parseSessionBindingKey(sidebarKey, input.platform);
|
||||
if (key) {
|
||||
// The sidebar accepts DOM codes with either Shift state, or literal characters.
|
||||
if (/^[A-Z]$/.test(sidebarKey)) key.modifiers = ['shift'];
|
||||
reservations.push({ key, path: 'subtitleSidebar.toggleKey' });
|
||||
if (/^Key[A-Z]$/.test(sidebarKey)) {
|
||||
reservations.push({
|
||||
key: { ...key, modifiers: ['shift'] },
|
||||
path: 'subtitleSidebar.toggleKey',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = resolveSessionSequenceConflicts(bindings, reservations);
|
||||
return { bindings: result.bindings, warnings: [...warnings, ...result.warnings] };
|
||||
}
|
||||
|
||||
export function buildPluginSessionBindingsArtifact(input: {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
|
||||
openRuntimeOptions: string | null | undefined;
|
||||
openJimaku: string | null | undefined;
|
||||
openTsukihime: string | null | undefined;
|
||||
openSubtitleSelection: string | null | undefined;
|
||||
openSubtitleGeneration: string | null | undefined;
|
||||
openSessionHelp: string | null | undefined;
|
||||
openControllerSelect: string | null | undefined;
|
||||
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
|
||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||
openSubtitleSelection:
|
||||
config.subtitleSelection?.enabled === true
|
||||
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
|
||||
: null,
|
||||
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
|
||||
+60
-18
@@ -466,6 +466,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
||||
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
||||
import {
|
||||
createSubtitleSelectionRuntime,
|
||||
openSubtitleSelectionModal,
|
||||
registerSubtitleSelectionIpc,
|
||||
} from './main/runtime/subtitle-selection';
|
||||
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
||||
@@ -4583,6 +4588,7 @@ const {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
flushQueuedMpvOsdNotifications();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
void refreshMpvSessionBindings();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -5235,20 +5241,32 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
|
||||
configDir: CONFIG_DIR,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
|
||||
createSessionBindingsRuntime({
|
||||
configDir: CONFIG_DIR,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onBindingsChanged: (bindings) =>
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
|
||||
onWarning: (warning) => {
|
||||
if (warning.kind !== 'conflict') return;
|
||||
overlayNotificationsRuntime.showOverlayNotification({
|
||||
id: `session-binding-conflict:${warning.path}`,
|
||||
title: 'Shortcut conflict',
|
||||
body: warning.message,
|
||||
variant: 'warning',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||
appendToMpvLogMainDeps: {
|
||||
@@ -5503,6 +5521,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openSubtitleSelection: () => {
|
||||
if (!configService.getConfig().subtitleSelection.enabled) return;
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSubtitleSelectionModal,
|
||||
'Subtitle selection overlay unavailable.',
|
||||
'Failed to open subtitle selection overlay.',
|
||||
);
|
||||
},
|
||||
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
@@ -5855,8 +5881,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getMpvInputBindings: () =>
|
||||
readMpvInputBindings({
|
||||
getMpvInputBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return readMpvInputBindings({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
||||
platform:
|
||||
@@ -5865,8 +5892,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
: process.platform === 'win32'
|
||||
? 'win32'
|
||||
: 'linux',
|
||||
}),
|
||||
getSessionBindings: () => appState.sessionBindings,
|
||||
});
|
||||
},
|
||||
getSessionBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return appState.sessionBindings;
|
||||
},
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||
@@ -6649,6 +6680,17 @@ function setOverlayVisible(visible: boolean): void {
|
||||
}
|
||||
|
||||
registerIpcRuntimeHandlers();
|
||||
registerSubtitleSelectionIpc({
|
||||
ipc: ipcMain,
|
||||
isAllowedSender: (sender) =>
|
||||
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
|
||||
(window) => window && !window.isDestroyed() && window.webContents === sender,
|
||||
),
|
||||
runtime: createSubtitleSelectionRuntime({
|
||||
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
}),
|
||||
});
|
||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||
getModelDirectory: () =>
|
||||
|
||||
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
if (
|
||||
diff.hotReloadFields.includes('shortcuts') ||
|
||||
diff.hotReloadFields.includes('subtitleSelection')
|
||||
) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
|
||||
const sequence: CompiledSessionBinding = {
|
||||
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
};
|
||||
let nativeKeys: unknown = [];
|
||||
let failDiscovery = false;
|
||||
let published: CompiledSessionBinding[] = [];
|
||||
const events: CompiledSessionBinding[][] = [];
|
||||
const warnings: string[] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
send: () => {},
|
||||
requestProperty: async () => {
|
||||
if (failDiscovery) throw new Error('temporarily unavailable');
|
||||
return nativeKeys;
|
||||
},
|
||||
};
|
||||
const runtime = createSessionBindingsRuntime({
|
||||
configDir: root,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
|
||||
getMpvClient: () => client,
|
||||
setSessionBindings: (bindings) => {
|
||||
published = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: () => {},
|
||||
logWarn: () => {},
|
||||
onBindingsChanged: (bindings) => events.push(bindings),
|
||||
onWarning: (warning) => warnings.push(warning.message),
|
||||
});
|
||||
const readArtifact = () =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
|
||||
try {
|
||||
runtime.persistSessionBindings([sequence]);
|
||||
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, []);
|
||||
assert.deepEqual(events.at(-1), readArtifact().bindings);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /mpv input binding "g"/);
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
|
||||
assert.equal(warnings.length, 1);
|
||||
failDiscovery = true;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
|
||||
failDiscovery = false;
|
||||
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
|
||||
assert.deepEqual(readArtifact().warnings, []);
|
||||
client.connected = false;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,16 +6,26 @@ import {
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
|
||||
import {
|
||||
reserveMpvSequencePrefixes,
|
||||
resolveSessionSequenceConflicts,
|
||||
} from '../../shared/session-key-sequences';
|
||||
import type { SessionBindingWarning } from '../../types/session-bindings';
|
||||
|
||||
export interface SessionBindingsRuntimeDeps {
|
||||
configDir: string;
|
||||
getKeybindings: () => Keybinding[];
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
getMpvClient: () =>
|
||||
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
|
||||
| null;
|
||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
|
||||
onWarning?: (warning: SessionBindingWarning) => void;
|
||||
}
|
||||
|
||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||
) => 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' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
sourceBindings = bindings;
|
||||
sourceWarnings = warnings;
|
||||
publishBindings();
|
||||
}
|
||||
|
||||
function publishBindings(): void {
|
||||
const client = deps.getMpvClient();
|
||||
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
|
||||
const result = resolveSessionSequenceConflicts(
|
||||
sourceBindings,
|
||||
reserveMpvSequencePrefixes(keys),
|
||||
);
|
||||
const warnings = [...sourceWarnings, ...result.warnings];
|
||||
const signature = JSON.stringify([
|
||||
result.bindings,
|
||||
warnings,
|
||||
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
]);
|
||||
if (signature === publishedSignature) return;
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
bindings: result.bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||
throw error;
|
||||
}
|
||||
deps.setSessionBindings(bindings);
|
||||
publishedSignature = signature;
|
||||
deps.setSessionBindings(result.bindings);
|
||||
deps.setSessionBindingsInitialized(true);
|
||||
const nextWarnings = new Set(warnings.map((warning) => warning.message));
|
||||
for (const warning of warnings) {
|
||||
if (reportedWarnings.has(warning.message)) continue;
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
deps.onWarning?.(warning);
|
||||
}
|
||||
reportedWarnings = nextWarnings;
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
try {
|
||||
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||
}
|
||||
}
|
||||
deps.onBindingsChanged?.(result.bindings);
|
||||
}
|
||||
|
||||
async function refreshMpvSessionBindings(): Promise<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 {
|
||||
const compiled = compileCurrentSessionBindings();
|
||||
for (const warning of compiled.warnings) {
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
}
|
||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||
void refreshMpvSessionBindings();
|
||||
}
|
||||
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
||||
const onSubtitleSelectionOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleSelectionOpen);
|
||||
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
|
||||
|
||||
type EmptyListener = () => void;
|
||||
@@ -463,6 +464,11 @@ const electronAPI: ElectronAPI = {
|
||||
) as Promise<boolean>,
|
||||
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
|
||||
onSubtitleSelectionOpen,
|
||||
getSubtitleSelection: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSelection),
|
||||
applySubtitleSelection: (
|
||||
request: import('./shared/subtitle-selection').SubtitleSelectionRequest,
|
||||
) => ipcRenderer.invoke(IPC_CHANNELS.request.applySubtitleSelection, request),
|
||||
onSubsyncManualOpen: onSubsyncManualOpenEvent,
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
||||
@@ -580,6 +586,14 @@ const electronAPI: ElectronAPI = {
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
||||
},
|
||||
onSessionBindingsChanged: (
|
||||
callback: (bindings: import('./types').CompiledSessionBinding[]) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.event.sessionBindingsChanged,
|
||||
(_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings),
|
||||
);
|
||||
},
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.event.configHotReload,
|
||||
|
||||
@@ -4,6 +4,7 @@ type ControllerInteractionModalState = {
|
||||
jimakuModalOpen: boolean;
|
||||
kikuModalOpen: boolean;
|
||||
runtimeOptionsModalOpen: boolean;
|
||||
subtitleSelectionModalOpen?: boolean;
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleGenerationModalOpen?: boolean;
|
||||
youtubePickerModalOpen: boolean;
|
||||
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
|
||||
state.jimakuModalOpen ||
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subtitleSelectionModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
Boolean(state.subtitleGenerationModalOpen) ||
|
||||
state.youtubePickerModalOpen ||
|
||||
|
||||
@@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
@@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async (
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: g-s opens subtitle selection only after the complete sequence', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'openSubtitleSelection', payload: undefined },
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
handlers.updateSessionBindings([]);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.equal(testGlobals.sessionActions.length, 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'sequence',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
},
|
||||
{
|
||||
sourcePath: 'single',
|
||||
originalKey: 'g',
|
||||
key: { code: 'KeyG', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['show-text', 'single'],
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an unfinished built-in y chord cannot start a configured sequence', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'sequence',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.equal(testGlobals.sessionActions.length, 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createKeyboardHandlers(
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -133,7 +134,10 @@ export function createKeyboardHandlers(
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
}
|
||||
|
||||
let pendingSequence: { prefix: string; expires: number } | null = null;
|
||||
|
||||
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||
pendingSequence = null;
|
||||
ctx.state.sessionBindings = bindings;
|
||||
ctx.state.sessionBindingMap = new Map(
|
||||
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
||||
@@ -1049,7 +1053,10 @@ export function createKeyboardHandlers(
|
||||
window.addEventListener('focus', () => {
|
||||
void importedMpvBindings.refresh();
|
||||
});
|
||||
window.addEventListener('blur', importedMpvBindings.releaseAll);
|
||||
window.addEventListener('blur', () => {
|
||||
pendingSequence = null;
|
||||
importedMpvBindings.releaseAll();
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearTimeout(lateScriptRefresh);
|
||||
importedMpvBindings.dispose();
|
||||
@@ -1103,6 +1110,13 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const sequence = pendingSequence;
|
||||
pendingSequence = null;
|
||||
if (ctx.state.subtitleSelectionModalOpen) {
|
||||
pendingSequence = null;
|
||||
options.handleSubtitleSelectionKeydown?.(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.subtitleGenerationModalOpen) {
|
||||
options.handleSubtitleGenerationKeydown?.(e);
|
||||
return;
|
||||
@@ -1187,6 +1201,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
if (isTextEntryTarget(e.target)) {
|
||||
pendingSequence = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1194,6 +1209,16 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
const sequenceKey = keyEventToString(e);
|
||||
if (sequence && !ctx.state.chordPending && Date.now() <= sequence.expires && !e.repeat) {
|
||||
const binding = ctx.state.sessionBindingMap.get(`${sequence.prefix}-${sequenceKey}`);
|
||||
if (binding) {
|
||||
e.preventDefault();
|
||||
dispatchSessionBinding(binding);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStatsOverlayToggle(e)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.toggleStatsOverlay();
|
||||
@@ -1276,6 +1301,16 @@ export function createKeyboardHandlers(
|
||||
dispatchSessionBinding(binding);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!e.repeat &&
|
||||
ctx.state.sessionBindings.some((binding) =>
|
||||
keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`),
|
||||
)
|
||||
) {
|
||||
pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
keyboardConfigLoaded &&
|
||||
!ctx.state.playlistBrowserModalOpen &&
|
||||
|
||||
@@ -833,6 +833,43 @@
|
||||
</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 class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -225,6 +225,8 @@ function describeSessionAction(
|
||||
return 'Open jimaku';
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openSubtitleSelection':
|
||||
return 'Select subtitle tracks';
|
||||
case 'openSubtitleGeneration':
|
||||
return 'Generate Japanese subtitles';
|
||||
case 'openYoutubePicker':
|
||||
@@ -268,6 +270,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
case 'openJimaku':
|
||||
case 'openTsukihime':
|
||||
case 'openCharacterDictionaryManager':
|
||||
case 'openSubtitleSelection':
|
||||
case 'openSubtitleGeneration':
|
||||
case 'openControllerSelect':
|
||||
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.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subtitleSelectionModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
state.subtitleGenerationModalOpen ||
|
||||
state.sessionHelpModalOpen,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selectio
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
@@ -153,6 +154,12 @@ const modalDescriptors = [
|
||||
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'subtitle-selection',
|
||||
isOpen: () => ctx.state.subtitleSelectionModalOpen,
|
||||
close: () => subtitleSelectionModal.close(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'subsync',
|
||||
isOpen: () => ctx.state.subsyncModalOpen,
|
||||
@@ -216,6 +223,9 @@ const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -293,6 +303,7 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
@@ -624,6 +635,9 @@ function registerModalOpenHandlers(): void {
|
||||
youtubePickerModal.closeYoutubePickerModal();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSubtitleSelectionOpen(() => {
|
||||
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
|
||||
});
|
||||
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||
runGuarded('subsync:manual-open', () => {
|
||||
subsyncModal.openSubsyncModal(payload);
|
||||
@@ -849,6 +863,7 @@ async function init(): Promise<void> {
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subtitleSelectionModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
subtitleGenerationModal.wireDomEvents();
|
||||
controllerSelectModal.wireDomEvents();
|
||||
@@ -858,6 +873,7 @@ async function init(): Promise<void> {
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
subtitleSelectionModal.dispose();
|
||||
subtitleGenerationModal.dispose();
|
||||
subtitleSidebarModal.disposeDomEvents();
|
||||
});
|
||||
@@ -867,9 +883,13 @@ async function init(): Promise<void> {
|
||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
|
||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||
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();
|
||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
|
||||
|
||||
@@ -89,6 +89,7 @@ export type RendererState = {
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleSelectionModalOpen: boolean;
|
||||
subtitleGenerationModalOpen: boolean;
|
||||
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
@@ -223,6 +224,7 @@ export function createRendererState(): RendererState {
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subtitleSelectionModalOpen: false,
|
||||
subtitleGenerationModalOpen: false,
|
||||
subsyncSubtitleTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
@@ -3228,6 +3228,16 @@ iframe[id^='yomitan-popup'],
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
||||
export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'subtitle-selection',
|
||||
'subtitle-generation',
|
||||
'jimaku',
|
||||
'tsukihime',
|
||||
@@ -51,6 +52,8 @@ export const IPC_CHANNELS = {
|
||||
dispatchSessionAction: 'session-action:dispatch',
|
||||
},
|
||||
request: {
|
||||
getSubtitleSelection: 'subtitle-selection:get',
|
||||
applySubtitleSelection: 'subtitle-selection:apply',
|
||||
requestSubtitleGenerationOpen: 'subtitle-generation:open',
|
||||
getSubtitleGenerationStatus: 'subtitle-generation:status',
|
||||
startSubtitleGeneration: 'subtitle-generation:start',
|
||||
@@ -143,6 +146,7 @@ export const IPC_CHANNELS = {
|
||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||
},
|
||||
event: {
|
||||
subtitleSelectionOpen: 'subtitle-selection:opened',
|
||||
subtitleGenerationOpen: 'subtitle-generation:opened',
|
||||
subtitleGenerationProgress: 'subtitle-generation:progress',
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -174,6 +178,7 @@ export const IPC_CHANNELS = {
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||
sessionBindingsChanged: 'session-bindings:changed',
|
||||
configHotReload: 'config:hot-reload',
|
||||
overlayNotification: 'overlay:notification',
|
||||
notificationHistoryToggle: 'notification-history:toggle',
|
||||
|
||||
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleSelection',
|
||||
'openSubtitleGeneration',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
|
||||
@@ -103,3 +103,13 @@ test('SubMiner ownership recognizes leading mpv prefixes without matching comman
|
||||
['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('+'));
|
||||
}
|
||||
|
||||
export function parseMpvInputBindingKeys(value: unknown): string[] {
|
||||
export function parseMpvInputBindingKeys(
|
||||
value: unknown,
|
||||
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
|
||||
): string[] {
|
||||
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) {
|
||||
const entry: unknown = candidate;
|
||||
if (
|
||||
@@ -94,8 +97,14 @@ export function parseMpvInputBindingKeys(value: unknown): string[] {
|
||||
entry.priority > previous.priority ||
|
||||
(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;
|
||||
openJimaku?: string | null;
|
||||
openTsukihime?: string | null;
|
||||
openSubtitleSelection?: string | null;
|
||||
openSubtitleGeneration?: string | null;
|
||||
openSessionHelp?: string | null;
|
||||
openControllerSelect?: string | null;
|
||||
@@ -152,6 +153,7 @@ export interface Config {
|
||||
shortcuts?: RawShortcutsConfig;
|
||||
secondarySub?: SecondarySubConfig;
|
||||
subsync?: SubsyncConfig;
|
||||
subtitleSelection?: { enabled?: boolean };
|
||||
subtitleGeneration?: Partial<SubtitleGenerationConfig>;
|
||||
startupWarmups?: StartupWarmupsConfig;
|
||||
subtitleStyle?: SubtitleStyleConfig;
|
||||
@@ -304,6 +306,7 @@ export interface ResolvedConfig {
|
||||
shortcuts: Required<ShortcutsConfig>;
|
||||
secondarySub: Required<SecondarySubConfig>;
|
||||
subsync: Required<SubsyncConfig>;
|
||||
subtitleSelection: { enabled: boolean };
|
||||
subtitleGeneration: SubtitleGenerationConfig;
|
||||
startupWarmups: {
|
||||
lowPowerMode: boolean;
|
||||
|
||||
@@ -525,6 +525,13 @@ export interface ElectronAPI {
|
||||
focusMainWindow: () => Promise<void>;
|
||||
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
|
||||
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;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
||||
@@ -604,6 +611,7 @@ export interface ElectronAPI {
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'subtitle-selection'
|
||||
| 'subtitle-generation'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
@@ -622,6 +630,7 @@ export interface ElectronAPI {
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'subtitle-selection'
|
||||
| 'subtitle-generation'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
@@ -637,6 +646,7 @@ export interface ElectronAPI {
|
||||
| 'changelog',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SessionActionId =
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
| 'openTsukihime'
|
||||
| 'openSubtitleSelection'
|
||||
| 'openSubtitleGeneration'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
|
||||
Reference in New Issue
Block a user