refactor: extract overlay shortcuts runtime for task 27.2

This commit is contained in:
2026-02-14 15:58:50 -08:00
parent 1fb8e2e168
commit 824443d93b
5 changed files with 565 additions and 339 deletions

View File

@@ -94,7 +94,6 @@ import {
createFieldGroupingOverlayRuntimeService,
createNumericShortcutRuntimeService,
createOverlayContentMeasurementStoreService,
createOverlayShortcutRuntimeHandlers,
createOverlayWindowService,
createTokenizerDepsRuntimeService,
cycleSecondarySubModeService,
@@ -116,9 +115,7 @@ import {
mineSentenceCardService,
openYomitanSettingsWindow,
playNextSubtitleRuntimeService,
refreshOverlayShortcutsRuntimeService,
registerGlobalShortcutsService,
registerOverlayShortcutsService,
replayCurrentSubtitleRuntimeService,
resolveJimakuApiKeyService,
runStartupBootstrapRuntimeService,
@@ -130,34 +127,31 @@ import {
setVisibleOverlayVisibleService,
shouldAutoInitializeOverlayRuntimeFromConfigService,
shouldBindVisibleOverlayToMpvSubVisibilityService,
shortcutMatchesInputForLocalFallback,
showMpvOsdRuntimeService,
startAppLifecycleService,
syncInvisibleOverlayMousePassthroughService,
syncOverlayShortcutsRuntimeService,
tokenizeSubtitleService,
triggerFieldGroupingService,
unregisterOverlayShortcutsRuntimeService,
updateCurrentMediaPathService,
updateInvisibleOverlayVisibilityService,
updateLastCardFromClipboardService,
updateVisibleOverlayVisibilityService,
} from "./core/services";
import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler";
import {
runAppReadyRuntimeService,
} from "./core/services/startup-service";
import type { AppReadyRuntimeDeps } from "./core/services/startup-service";
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
import {
createAppLifecycleRuntimeDeps as createAppLifecycleRuntimeDepsBuilder,
createAppReadyRuntimeDeps as createAppReadyRuntimeDepsBuilder,
createAppLifecycleRuntimeDeps,
createAppReadyRuntimeDeps,
} from "./main/app-lifecycle";
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
import {
registerIpcRuntimeServices,
} from "./main/ipc-runtime";
import { handleCliCommandRuntimeService } from "./main/cli-runtime";
import {
handleCliCommandRuntimeServiceWithContext,
} from "./main/cli-runtime";
import {
runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime,
@@ -166,6 +160,9 @@ import {
createOverlayModalRuntimeService,
type OverlayHostedModal,
} from "./main/overlay-runtime";
import {
createOverlayShortcutsRuntimeService,
} from "./main/overlay-shortcuts-runtime";
import {
applyStartupState,
createAppState,
@@ -177,9 +174,6 @@ import {
DEFAULT_KEYBINDINGS,
generateConfigTemplate,
} from "./config";
import type {
AppLifecycleDepsRuntimeOptions,
} from "./core/services/app-lifecycle-service";
if (process.platform === "linux") {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
@@ -290,6 +284,44 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered,
setShortcutsRegistered: (registered) => {
appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();
},
openJimaku: () => {
sendToActiveOverlayWindow("jimaku:open", undefined, {
restoreOnModalClose: "jimaku",
});
},
markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
copyCurrentSubtitle();
},
toggleSecondarySubMode: () => cycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
mineSentenceCard: () => mineSentenceCard(),
mineSentenceMultiple: (timeoutMs) => {
startPendingMineSentenceMultiple(timeoutMs);
},
cancelPendingMultiCopy: () => {
cancelPendingMultiCopy();
},
cancelPendingMineSentenceMultiple: () => {
cancelPendingMineSentenceMultiple();
},
});
function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null {
return appState.fieldGroupingResolver;
@@ -530,7 +562,125 @@ const startupState = runStartupBootstrapRuntimeService(
startAppLifecycle: (args: CliArgs) => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()),
createAppLifecycleDepsRuntimeService(
createAppLifecycleRuntimeDeps({
app,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => {
await runAppReadyRuntimeService(
createAppReadyRuntimeDeps({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(
getResolvedConfig(),
DEFAULT_KEYBINDINGS,
);
},
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
reloadConfig: () => {
configService.reloadConfig();
appLogger.logInfo(
`Using config file: ${configService.getConfigPath()}`,
);
},
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
onOptionsChanged: () => {
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: "hover",
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
const tokenizer = new MecabTokenizer();
appState.mecabTokenizer = tokenizer;
await tokenizer.checkAvailability();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker;
},
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
}),
);
},
onWillQuitCleanup: () => {
restorePreviousSecondarySubVisibility();
globalShortcut.unregisterAll();
subtitleWsService.stop();
texthookerService.stop();
if (
appState.yomitanParserWindow &&
!appState.yomitanParserWindow.isDestroyed()
) {
appState.yomitanParserWindow.destroy();
}
appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null;
if (appState.windowTracker) {
appState.windowTracker.stop();
}
if (appState.mpvClient && appState.mpvClient.socket) {
appState.mpvClient.socket.destroy();
}
if (appState.reconnectTimer) {
clearTimeout(appState.reconnectTimer);
}
if (appState.subtitleTimingTracker) {
appState.subtitleTimingTracker.destroy();
}
if (appState.ankiIntegration) {
appState.ankiIntegration.destroy();
}
},
shouldRestoreWindowsOnActivate: () =>
appState.overlayRuntimeInitialized &&
BrowserWindow.getAllWindows().length === 0,
restoreWindowsOnActivate: () => {
createMainWindow();
createInvisibleWindow();
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
},
}),
),
);
},
}),
@@ -538,176 +688,49 @@ const startupState = runStartupBootstrapRuntimeService(
applyStartupState(appState, startupState);
function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
return createAppLifecycleRuntimeDepsBuilder({
app,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (
nextArgs: CliArgs,
source: CliCommandSource,
) => handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => {
await runAppReadyRuntimeService(createAppReadyRuntimeDeps());
},
onWillQuitCleanup: () => {
restorePreviousSecondarySubVisibility();
globalShortcut.unregisterAll();
subtitleWsService.stop();
texthookerService.stop();
if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) {
appState.yomitanParserWindow.destroy();
}
appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null;
if (appState.windowTracker) {
appState.windowTracker.stop();
}
if (appState.mpvClient && appState.mpvClient.socket) {
appState.mpvClient.socket.destroy();
}
if (appState.reconnectTimer) {
clearTimeout(appState.reconnectTimer);
}
if (appState.subtitleTimingTracker) {
appState.subtitleTimingTracker.destroy();
}
if (appState.ankiIntegration) {
appState.ankiIntegration.destroy();
}
},
shouldRestoreWindowsOnActivate: () =>
appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
restoreWindowsOnActivate: () => {
createMainWindow();
createInvisibleWindow();
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
},
});
}
function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
return createAppReadyRuntimeDepsBuilder({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
},
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
reloadConfig: () => {
configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
},
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
onOptionsChanged: () => {
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: "hover",
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
const tokenizer = new MecabTokenizer();
appState.mecabTokenizer = tokenizer;
await tokenizer.checkAvailability();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker;
},
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
});
}
function handleCliCommand(
args: CliArgs,
source: CliCommandSource = "initial",
): void {
handleCliCommandRuntimeService(args, source, {
mpv: {
getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath;
},
getClient: () => appState.mpvClient,
showOsd: (text: string) => showMpvOsd(text),
handleCliCommandRuntimeServiceWithContext(args, source, {
getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath;
},
texthooker: {
service: texthookerService,
getPort: () => appState.texthookerPort,
setPort: (port: number) => {
appState.texthookerPort = port;
},
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => {
void shell.openExternal(url).catch((error) => {
console.error(`Failed to open browser for texthooker URL: ${url}`, error);
});
},
getClient: () => appState.mpvClient,
showOsd: (text: string) => showMpvOsd(text),
texthookerService,
getTexthookerPort: () => appState.texthookerPort,
setTexthookerPort: (port: number) => {
appState.texthookerPort = port;
},
overlay: {
isInitialized: () => appState.overlayRuntimeInitialized,
initialize: () => initializeOverlayRuntime(),
toggleVisible: () => toggleVisibleOverlay(),
toggleInvisible: () => toggleInvisibleOverlay(),
setVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
},
mining: {
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
},
ui: {
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
},
app: {
stop: () => app.quit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => {
void shell.openExternal(url).catch((error) => {
console.error(`Failed to open browser for texthooker URL: ${url}`, error);
});
},
isOverlayInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlay: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => app.quit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
log: (message: string) => {
@@ -875,7 +898,7 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow {
? overlayManager.getVisibleOverlayVisible()
: overlayManager.getInvisibleOverlayVisible(),
tryHandleOverlayShortcutLocalFallback: (input) =>
tryHandleOverlayShortcutLocalFallback(input),
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => {
if (windowKind === "visible") {
overlayManager.setMainWindow(null);
@@ -932,9 +955,7 @@ function initializeOverlayRuntime(): void {
updateInvisibleOverlayVisibility();
},
getOverlayWindows: () => getOverlayWindows(),
syncOverlayShortcuts: () => {
syncOverlayShortcuts();
},
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker) => {
appState.windowTracker = tracker;
},
@@ -980,46 +1001,6 @@ function registerGlobalShortcuts(): void {
function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); }
function getOverlayShortcutRuntimeHandlers() {
return createOverlayShortcutRuntimeHandlers(
{
showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptions: () => {
openRuntimeOptionsPalette();
},
openJimaku: () => {
sendToActiveOverlayWindow("jimaku:open", undefined, {
restoreOnModalClose: "jimaku",
});
},
markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
copyCurrentSubtitle();
},
toggleSecondarySub: () => cycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsync: () => triggerSubsyncFromConfig(),
mineSentence: () => mineSentenceCard(),
mineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultiple(timeoutMs);
},
},
);
}
function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean {
return runOverlayShortcutLocalFallback(
input,
getConfiguredShortcuts(),
shortcutMatchesInputForLocalFallback,
getOverlayShortcutRuntimeHandlers().fallbackHandlers,
);
}
function cycleSecondarySubMode(): void {
cycleSecondarySubModeService(
{
@@ -1201,42 +1182,18 @@ function handleMineSentenceDigit(count: number): void {
}
function registerOverlayShortcuts(): void {
appState.shortcutsRegistered = registerOverlayShortcutsService(
getConfiguredShortcuts(),
getOverlayShortcutRuntimeHandlers().overlayHandlers,
);
}
function getOverlayShortcutLifecycleDeps() {
return {
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getOverlayHandlers: () => getOverlayShortcutRuntimeHandlers().overlayHandlers,
cancelPendingMultiCopy: () => cancelPendingMultiCopy(),
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultiple(),
};
overlayShortcutsRuntime.registerOverlayShortcuts();
}
function unregisterOverlayShortcuts(): void {
appState.shortcutsRegistered = unregisterOverlayShortcutsRuntimeService(
appState.shortcutsRegistered,
getOverlayShortcutLifecycleDeps(),
);
overlayShortcutsRuntime.unregisterOverlayShortcuts();
}
function shouldOverlayShortcutsBeActive(): boolean { return appState.overlayRuntimeInitialized; }
function syncOverlayShortcuts(): void {
appState.shortcutsRegistered = syncOverlayShortcutsRuntimeService(
shouldOverlayShortcutsBeActive(),
appState.shortcutsRegistered,
getOverlayShortcutLifecycleDeps(),
);
overlayShortcutsRuntime.syncOverlayShortcuts();
}
function refreshOverlayShortcuts(): void {
appState.shortcutsRegistered = refreshOverlayShortcutsRuntimeService(
shouldOverlayShortcutsBeActive(),
appState.shortcutsRegistered,
getOverlayShortcutLifecycleDeps(),
);
overlayShortcutsRuntime.refreshOverlayShortcuts();
}
function updateVisibleOverlayVisibility(): void {