Compare commits

...
Author SHA1 Message Date
sudacode ca673735ba 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
2026-09-21 21:32:41 -07:00
54 changed files with 1397 additions and 52 deletions
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: config
- Documented the subtitle selector setting, shortcut override, and primary/secondary track controls.
+5
View File
@@ -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.
+10
View File
@@ -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.
+8
View File
@@ -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
+10
View File
@@ -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.
+3
View File
@@ -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.
+2
View File
@@ -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.
+122 -7
View File
@@ -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
+42 -9
View File
@@ -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")
+1
View File
@@ -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,
+1
View File
@@ -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',
+7
View File
@@ -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: [
+7 -1
View File
@@ -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',
+20
View File
@@ -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);
});
+4
View File
@@ -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' ||
+2 -2
View File
@@ -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');
+4
View File
@@ -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,
);
}
});
+43 -1
View File
@@ -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: {
+5
View File
@@ -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')),
+47 -5
View File
@@ -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,7 +5241,8 @@ const {
},
});
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
@@ -5248,6 +5255,17 @@ const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionB
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({
@@ -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 });
}
});
+83 -7
View File
@@ -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 };
}
+101
View File
@@ -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/);
});
+117
View File
@@ -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);
});
}
+14
View File
@@ -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 ||
+84
View File
@@ -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();
}
});
+36 -1
View File
@@ -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 &&
+37
View File
@@ -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':
+172
View File
@@ -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() };
}
+1
View File
@@ -10,6 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
state.youtubePickerModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subtitleSelectionModalOpen ||
state.subsyncModalOpen ||
state.subtitleGenerationModalOpen ||
state.sessionHelpModalOpen,
+21 -1
View File
@@ -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);
+2
View File
@@ -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,
+10
View File
@@ -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;
+5
View File
@@ -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',
+1
View File
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerDebug',
'openJimaku',
'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration',
'openYoutubePicker',
'openPlaylistBrowser',
+10
View File
@@ -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']);
});
+13 -4
View File
@@ -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);
}
+71
View File
@@ -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}"`,
},
];
});
}
+33
View File
@@ -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);
}
+3
View File
@@ -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;
+10
View File
@@ -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;
}
+1
View File
@@ -23,6 +23,7 @@ export type SessionActionId =
| 'openControllerDebug'
| 'openJimaku'
| 'openTsukihime'
| 'openSubtitleSelection'
| 'openSubtitleGeneration'
| 'openYoutubePicker'
| 'openPlaylistBrowser'