fix: restore linux modal shortcuts

This commit is contained in:
2026-04-04 00:14:53 -07:00
parent 09d8b52fbf
commit 52249db5b4
18 changed files with 293 additions and 2 deletions

View File

@@ -0,0 +1,58 @@
---
id: TASK-277
title: Restore Linux shortcut-backed modal actions after Electron 39 upgrade
status: Done
assignee:
- codex
created_date: '2026-04-04 06:49'
updated_date: '2026-04-04 07:08'
labels:
- bug
- linux
- electron
- shortcuts
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Linux builds on Electron 39 no longer open the runtime options, Jimaku, or Subsync flows from their configured shortcuts (`Ctrl/Cmd+Shift+O`, `Ctrl+Shift+J`, `Ctrl+Alt+S`). Other renderer-driven modals like session help, subtitle sidebar, and playlist browser still work, which points to the Linux `globalShortcut` / overlay shortcut path rather than modal rendering in general. Investigate the Electron 39 regression and restore these Linux shortcut-triggered actions without regressing macOS or Windows behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 On Linux, the configured runtime options, Jimaku, and Subsync shortcuts trigger their existing actions again under Electron 39.
- [x] #2 The fix is covered by automated tests around the Linux shortcut/backend behavior that regressed.
- [x] #3 A changelog fragment is added for the Linux shortcut regression fix.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Extend the special-command / mpv IPC command path to cover Jimaku alongside the existing runtime-options and subsync commands.
2. Add tests for IPC command dispatch and any keybinding/session-help metadata affected by the new special command.
3. Route Linux modal-opening shortcuts through the working mpv/IPC command path instead of depending on Electron globalShortcut delivery for those actions.
4. Re-run targeted tests, then the handoff verification gate, and update the changelog/task summary to reflect the final root cause and fix.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User approved plan; proceeding with test-first implementation.
Root cause: Linux startup unconditionally enabled Electron's GlobalShortcutsPortal path, so Electron 39 X11 sessions were routed through the portal-backed globalShortcut path even though that path should only be used for Wayland-style launches. The affected runtime-options, Jimaku, and Subsync actions all depend on that shortcut path, while renderer-driven modals did not regress.
User retested after the portal gating fix; issue persists. Updating investigation scope from portal selection alone to Linux overlay shortcut delivery/registration under Electron 39.
Revised fix path: Linux renderer now matches configured overlay shortcuts for runtime options, subsync, and Jimaku locally, then dispatches the existing mpv/IPC special-command route instead of depending on Electron globalShortcut delivery.
Added Jimaku mpv special command plumbing through IPC/runtime deps and exposed an overlay-runtime helper for opening the Jimaku modal from that IPC path.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored Linux shortcut-backed modal actions by routing runtime options, Subsync, and Jimaku through the working mpv/IPC special-command path. Added renderer-side Linux shortcut matching for configured overlay shortcuts, threaded a new Jimaku special command through IPC/runtime deps, refreshed configured shortcuts on hot reload, and covered the regression with IPC/runtime keyboard tests. Verification: bun test src/core/services/ipc-command.test.ts src/renderer/handlers/keyboard.test.ts src/main/runtime/ipc-mpv-command-main-deps.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1 @@
- Linux: Restored the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path.

View File

@@ -41,6 +41,7 @@ export interface ConfigTemplateSection {
export const SPECIAL_COMMANDS = { export const SPECIAL_COMMANDS = {
SUBSYNC_TRIGGER: '__subsync-trigger', SUBSYNC_TRIGGER: '__subsync-trigger',
RUNTIME_OPTIONS_OPEN: '__runtime-options-open', RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',

View File

@@ -10,6 +10,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
specialCommands: { specialCommands: {
SUBSYNC_TRIGGER: '__subsync-trigger', SUBSYNC_TRIGGER: '__subsync-trigger',
RUNTIME_OPTIONS_OPEN: '__runtime-options-open', RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
@@ -24,6 +25,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette: () => {
calls.push('runtime-options'); calls.push('runtime-options');
}, },
openJimaku: () => {
calls.push('jimaku');
},
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
@@ -114,6 +118,14 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
assert.deepEqual(osd, []); assert.deepEqual(osd, []);
}); });
test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__jimaku-open'], options);
assert.deepEqual(calls, ['jimaku']);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => { test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
const { options, calls, sentCommands, osd } = createOptions(); const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__playlist-browser-open'], options); handleMpvCommandFromIpc(['__playlist-browser-open'], options);

View File

@@ -9,6 +9,7 @@ export interface HandleMpvCommandFromIpcOptions {
specialCommands: { specialCommands: {
SUBSYNC_TRIGGER: string; SUBSYNC_TRIGGER: string;
RUNTIME_OPTIONS_OPEN: string; RUNTIME_OPTIONS_OPEN: string;
JIMAKU_OPEN: string;
RUNTIME_OPTION_CYCLE_PREFIX: string; RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string; REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string; PLAY_NEXT_SUBTITLE: string;
@@ -19,6 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
}; };
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -94,6 +96,11 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (first === options.specialCommands.JIMAKU_OPEN) {
options.openJimaku();
return;
}
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) { if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
void options.openYoutubeTrackPicker(); void options.openYoutubeTrackPicker();
return; return;

View File

@@ -4191,6 +4191,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(), openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => { cycleRuntimeOption: (id, direction) => {

View File

@@ -197,6 +197,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle']; runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openJimaku: HandleMpvCommandFromIpcOptions['openJimaku'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
@@ -368,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
specialCommands: params.specialCommands, specialCommands: params.specialCommands,
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openJimaku: params.openJimaku,
openYoutubeTrackPicker: params.openYoutubeTrackPicker, openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser, openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,

View File

@@ -12,6 +12,7 @@ type MpvPropertyClientLike = {
export interface MpvCommandFromIpcRuntimeDeps { export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
specialCommands: SPECIAL_COMMANDS, specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJimaku: deps.openJimaku,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker, openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser, openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,

View File

@@ -22,6 +22,7 @@ export interface OverlayModalRuntime {
}, },
) => boolean; ) => boolean;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
@@ -307,6 +308,12 @@ export function createOverlayModalRuntimeService(
}); });
}; };
const openJimaku = (): void => {
sendToActiveOverlayWindow('jimaku:open', undefined, {
restoreOnModalClose: 'jimaku',
});
};
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal); restoreVisibleOverlayOnModalClose.delete(modal);
@@ -379,6 +386,7 @@ export function createOverlayModalRuntimeService(
return { return {
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
openJimaku,
handleOverlayModalClosed, handleOverlayModalClosed,
notifyOverlayModalOpened, notifyOverlayModalOpened,
waitForModalOpen, waitForModalOpen,

View File

@@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),

View File

@@ -13,6 +13,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
buildMpvCommandDeps: () => ({ buildMpvCommandDeps: () => ({
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),

View File

@@ -10,6 +10,7 @@ test('handle mpv command handler forwards command and built deps', () => {
const deps = { const deps = {
triggerSubsyncFromConfig: () => {}, triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),

View File

@@ -7,6 +7,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({ const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({
triggerSubsyncFromConfig: () => calls.push('subsync'), triggerSubsyncFromConfig: () => calls.push('subsync'),
openRuntimeOptionsPalette: () => calls.push('palette'), openRuntimeOptionsPalette: () => calls.push('palette'),
openJimaku: () => calls.push('jimaku'),
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
@@ -28,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig(); deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
deps.openJimaku();
void deps.openYoutubeTrackPicker(); void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser(); void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
@@ -42,6 +44,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'subsync', 'subsync',
'palette', 'palette',
'jimaku',
'youtube-picker', 'youtube-picker',
'playlist-browser', 'playlist-browser',
'osd:hello', 'osd:hello',

View File

@@ -6,6 +6,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
return (): MpvCommandFromIpcRuntimeDeps => ({ return (): MpvCommandFromIpcRuntimeDeps => ({
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(), openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),

View File

@@ -53,6 +53,21 @@ function installKeyboardTestGlobals() {
let playbackPausedResponse: boolean | null = false; let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote'; let statsToggleKey = 'Backquote';
let markWatchedKey = 'KeyW'; let markWatchedKey = 'KeyW';
let configuredShortcuts = {
copySubtitle: '',
copySubtitleMultiple: '',
updateLastCardFromClipboard: '',
triggerFieldGrouping: '',
triggerSubsync: 'Ctrl+Alt+S',
mineSentence: '',
mineSentenceMultiple: '',
multiCopyTimeoutMs: 1000,
toggleSecondarySub: '',
markAudioCard: '',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
toggleVisibleOverlayGlobal: '',
};
let markActiveVideoWatchedResult = true; let markActiveVideoWatchedResult = true;
let markActiveVideoWatchedCalls = 0; let markActiveVideoWatchedCalls = 0;
let statsToggleOverlayCalls = 0; let statsToggleOverlayCalls = 0;
@@ -138,6 +153,7 @@ function installKeyboardTestGlobals() {
}, },
electronAPI: { electronAPI: {
getKeybindings: async () => [], getKeybindings: async () => [],
getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => { sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command); mpvCommands.push(command);
}, },
@@ -273,6 +289,9 @@ function installKeyboardTestGlobals() {
setMarkWatchedKey: (value: string) => { setMarkWatchedKey: (value: string) => {
markWatchedKey = value; markWatchedKey = value;
}, },
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
configuredShortcuts = value;
},
setMarkActiveVideoWatchedResult: (value: boolean) => { setMarkActiveVideoWatchedResult: (value: boolean) => {
markActiveVideoWatchedResult = value; markActiveVideoWatchedResult = value;
}, },
@@ -315,6 +334,7 @@ function createKeyboardHandlerHarness() {
overlay: testGlobals.overlay, overlay: testGlobals.overlay,
}, },
platform: { platform: {
isLinuxPlatform: false,
shouldToggleMouseIgnore: false, shouldToggleMouseIgnore: false,
isMacOSPlatform: false, isMacOSPlatform: false,
isModalLayer: false, isModalLayer: false,
@@ -765,6 +785,51 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
} }
}); });
test('linux overlay shortcut: Ctrl+Alt+S dispatches subsync special command locally', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
ctx.platform.isLinuxPlatform = true;
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]);
} finally {
testGlobals.restore();
}
});
test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
ctx.platform.isLinuxPlatform = true;
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]);
} finally {
testGlobals.restore();
}
});
test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
ctx.platform.isLinuxPlatform = true;
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => { test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -1,4 +1,5 @@
import type { Keybinding } from '../../types'; import { SPECIAL_COMMANDS } from '../../config/definitions';
import type { Keybinding, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context'; import type { RendererContext } from '../context';
import { import {
YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HIDDEN_EVENT,
@@ -35,6 +36,7 @@ export function createKeyboardHandlers(
// Timeout for the modal chord capture window (e.g. Y followed by H/K). // Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000; const CHORD_TIMEOUT_MS = 1000;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
const linuxOverlayShortcutCommands = new Map<string, (string | number)[]>();
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false; let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false; let resetSelectionToStartOnNextSubtitleSync = false;
@@ -74,6 +76,117 @@ export function createKeyboardHandlers(
return parts.join('+'); return parts.join('+');
} }
function acceleratorToKeyToken(token: string): string | null {
const normalized = token.trim();
if (!normalized) return null;
if (/^[a-z]$/i.test(normalized)) {
return `Key${normalized.toUpperCase()}`;
}
if (/^[0-9]$/.test(normalized)) {
return `Digit${normalized}`;
}
const exactMap: Record<string, string> = {
space: 'Space',
tab: 'Tab',
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
backspace: 'Backspace',
delete: 'Delete',
slash: 'Slash',
backslash: 'Backslash',
minus: 'Minus',
plus: 'Equal',
equal: 'Equal',
comma: 'Comma',
period: 'Period',
quote: 'Quote',
semicolon: 'Semicolon',
bracketleft: 'BracketLeft',
bracketright: 'BracketRight',
backquote: 'Backquote',
};
const lower = normalized.toLowerCase();
if (exactMap[lower]) return exactMap[lower];
if (/^key[a-z]$/i.test(normalized) || /^digit[0-9]$/i.test(normalized)) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
}
if (/^arrow(?:up|down|left|right)$/i.test(normalized)) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
}
if (/^f\d{1,2}$/i.test(normalized)) {
return normalized.toUpperCase();
}
return null;
}
function acceleratorToKeyString(accelerator: string): string | null {
const normalized = accelerator
.replace(/\s+/g, '')
.replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) return null;
const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) return null;
const eventParts: string[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
eventParts.push('Ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
eventParts.push('Alt');
continue;
}
if (lower === 'shift') {
eventParts.push('Shift');
continue;
}
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
eventParts.push('Meta');
continue;
}
if (lower === 'commandorcontrol') {
eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl');
continue;
}
return null;
}
const normalizedKey = acceleratorToKeyToken(keyToken);
if (!normalizedKey) return null;
eventParts.push(normalizedKey);
return eventParts.join('+');
}
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
linuxOverlayShortcutCommands.clear();
const bindings: Array<[string | null, (string | number)[]]> = [
[shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]],
[shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]],
[shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]],
];
for (const [accelerator, command] of bindings) {
if (!accelerator) continue;
const keyString = acceleratorToKeyString(accelerator);
if (keyString) {
linuxOverlayShortcutCommands.set(keyString, command);
}
}
}
async function refreshConfiguredShortcuts(): Promise<void> {
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
}
function dispatchYomitanPopupKeydown( function dispatchYomitanPopupKeydown(
key: string, key: string,
code: string, code: string,
@@ -779,12 +892,14 @@ export function createKeyboardHandlers(
} }
async function setupMpvInputForwarding(): Promise<void> { async function setupMpvInputForwarding(): Promise<void> {
const [keybindings, statsToggleKey, markWatchedKey] = await Promise.all([ const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
window.electronAPI.getKeybindings(), window.electronAPI.getKeybindings(),
window.electronAPI.getConfiguredShortcuts(),
window.electronAPI.getStatsToggleKey(), window.electronAPI.getStatsToggleKey(),
window.electronAPI.getMarkWatchedKey(), window.electronAPI.getMarkWatchedKey(),
]); ]);
updateKeybindings(keybindings); updateKeybindings(keybindings);
updateConfiguredShortcuts(shortcuts);
ctx.state.statsToggleKey = statsToggleKey; ctx.state.statsToggleKey = statsToggleKey;
ctx.state.markWatchedKey = markWatchedKey; ctx.state.markWatchedKey = markWatchedKey;
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
@@ -982,6 +1097,14 @@ export function createKeyboardHandlers(
} }
const keyString = keyEventToString(e); const keyString = keyEventToString(e);
const linuxOverlayCommand = ctx.platform.isLinuxPlatform
? linuxOverlayShortcutCommands.get(keyString)
: undefined;
if (linuxOverlayCommand) {
e.preventDefault();
dispatchConfiguredMpvCommand(linuxOverlayCommand);
return;
}
const command = ctx.state.keybindingsMap.get(keyString); const command = ctx.state.keybindingsMap.get(keyString);
if (command) { if (command) {
@@ -1015,6 +1138,7 @@ export function createKeyboardHandlers(
return { return {
setupMpvInputForwarding, setupMpvInputForwarding,
refreshConfiguredShortcuts,
updateKeybindings, updateKeybindings,
syncKeyboardTokenSelection, syncKeyboardTokenSelection,
handleSubtitleContentUpdated, handleSubtitleContentUpdated,

View File

@@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string {
} }
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
@@ -165,6 +166,7 @@ function sectionForCommand(command: (string | number)[]): string {
if ( if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN || first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) { ) {

View File

@@ -621,6 +621,7 @@ async function init(): Promise<void> {
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
runGuarded('config:hot-reload', () => { runGuarded('config:hot-reload', () => {
keyboardHandlers.updateKeybindings(payload.keybindings); keyboardHandlers.updateKeybindings(payload.keybindings);
void keyboardHandlers.refreshConfiguredShortcuts();
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar; ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;