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

@@ -20,7 +20,6 @@ import { AnkiConnectClient } from "./anki-connect";
import { SubtitleTimingTracker } from "./subtitle-timing-tracker"; import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
import { MediaGenerator } from "./media-generator"; import { MediaGenerator } from "./media-generator";
import * as path from "path"; import * as path from "path";
import axios from "axios";
import { import {
AnkiConnectConfig, AnkiConnectConfig,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
@@ -31,6 +30,11 @@ import {
} from "./types"; } from "./types";
import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config"; import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config";
import { createLogger } from "./logger"; import { createLogger } from "./logger";
import {
AiTranslateCallbacks,
AiTranslateRequest,
translateSentenceWithAi,
} from "./anki-integration/ai";
const log = createLogger("anki").child("integration"); const log = createLogger("anki").child("integration");
@@ -134,93 +138,6 @@ export class AnkiIntegration {
this.fieldGroupingCallback = fieldGroupingCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null;
} }
private extractAiText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
const parts: string[] = [];
for (const item of content) {
if (
item &&
typeof item === "object" &&
"type" in item &&
(item as { type?: unknown }).type === "text" &&
"text" in item &&
typeof (item as { text?: unknown }).text === "string"
) {
parts.push((item as { text: string }).text);
}
}
return parts.join("").trim();
}
private normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
if (/\/v1$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}/v1`;
}
private async translateSentenceWithAi(
sentence: string,
): Promise<string | null> {
const ai = this.config.ai ?? DEFAULT_ANKI_CONNECT_CONFIG.ai;
if (!ai) {
return null;
}
const apiKey = ai?.apiKey?.trim();
if (!apiKey) {
return null;
}
const baseUrl = this.normalizeOpenAiBaseUrl(
ai.baseUrl || "https://openrouter.ai/api",
);
const model = ai.model || "openai/gpt-4o-mini";
const targetLanguage = ai.targetLanguage || "English";
const defaultSystemPrompt =
"You are a translation engine. Return only the translated text with no explanations.";
const systemPrompt = ai.systemPrompt?.trim() || defaultSystemPrompt;
try {
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
temperature: 0,
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: `Translate this text to ${targetLanguage}:\n\n${sentence}`,
},
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 15000,
},
);
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
| { message?: { content?: unknown } }
| undefined;
const translated = this.extractAiText(content?.message?.content);
return translated || null;
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown translation error";
log.warn("AI translation failed:", message);
return null;
}
}
private getLapisConfig(): { private getLapisConfig(): {
enabled: boolean; enabled: boolean;
sentenceCardModel?: string; sentenceCardModel?: string;
@@ -1528,7 +1445,18 @@ export class AnkiIntegration {
const shouldAttemptAiTranslation = const shouldAttemptAiTranslation =
aiEnabled && (alwaysUseAiTranslation || !hasSecondarySub); aiEnabled && (alwaysUseAiTranslation || !hasSecondarySub);
if (shouldAttemptAiTranslation) { if (shouldAttemptAiTranslation) {
const translated = await this.translateSentenceWithAi(sentence); const request: AiTranslateRequest = {
sentence,
apiKey: aiConfig?.apiKey || "",
baseUrl: aiConfig?.baseUrl,
model: aiConfig?.model,
targetLanguage: aiConfig?.targetLanguage,
systemPrompt: aiConfig?.systemPrompt,
};
const callbacks: AiTranslateCallbacks = {
logWarning: (message: string) => log.warn(message),
};
const translated = await translateSentenceWithAi(request, callbacks);
if (translated) { if (translated) {
backText = translated; backText = translated;
} else if (!hasSecondarySub) { } else if (!hasSecondarySub) {

103
src/anki-integration/ai.ts Normal file
View File

@@ -0,0 +1,103 @@
import axios from "axios";
import { DEFAULT_ANKI_CONNECT_CONFIG } from "../config";
const DEFAULT_AI_SYSTEM_PROMPT =
"You are a translation engine. Return only the translated text with no explanations.";
export function extractAiText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
const parts: string[] = [];
for (const item of content) {
if (
item &&
typeof item === "object" &&
"type" in item &&
(item as { type?: unknown }).type === "text" &&
"text" in item &&
typeof (item as { text?: unknown }).text === "string"
) {
parts.push((item as { text: string }).text);
}
}
return parts.join("").trim();
}
export function normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
if (/\/v1$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}/v1`;
}
export interface AiTranslateRequest {
sentence: string;
apiKey: string;
baseUrl?: string;
model?: string;
targetLanguage?: string;
systemPrompt?: string;
}
export interface AiTranslateCallbacks {
logWarning: (message: string) => void;
}
export async function translateSentenceWithAi(
request: AiTranslateRequest,
callbacks: AiTranslateCallbacks,
): Promise<string | null> {
const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai;
if (!request.apiKey.trim()) {
return null;
}
const baseUrl = normalizeOpenAiBaseUrl(
request.baseUrl || aiConfig.baseUrl || "https://openrouter.ai/api",
);
const model = request.model || "openai/gpt-4o-mini";
const targetLanguage = request.targetLanguage || "English";
const prompt =
request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
try {
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
temperature: 0,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`,
},
],
},
{
headers: {
Authorization: `Bearer ${request.apiKey}`,
"Content-Type": "application/json",
},
timeout: 15000,
},
);
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
| { message?: { content?: unknown } }
| undefined;
return extractAiText(content?.message?.content) || null;
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown translation error";
callbacks.logWarning(`AI translation failed: ${message}`);
return null;
}
}

View File

@@ -94,7 +94,6 @@ import {
createFieldGroupingOverlayRuntimeService, createFieldGroupingOverlayRuntimeService,
createNumericShortcutRuntimeService, createNumericShortcutRuntimeService,
createOverlayContentMeasurementStoreService, createOverlayContentMeasurementStoreService,
createOverlayShortcutRuntimeHandlers,
createOverlayWindowService, createOverlayWindowService,
createTokenizerDepsRuntimeService, createTokenizerDepsRuntimeService,
cycleSecondarySubModeService, cycleSecondarySubModeService,
@@ -116,9 +115,7 @@ import {
mineSentenceCardService, mineSentenceCardService,
openYomitanSettingsWindow, openYomitanSettingsWindow,
playNextSubtitleRuntimeService, playNextSubtitleRuntimeService,
refreshOverlayShortcutsRuntimeService,
registerGlobalShortcutsService, registerGlobalShortcutsService,
registerOverlayShortcutsService,
replayCurrentSubtitleRuntimeService, replayCurrentSubtitleRuntimeService,
resolveJimakuApiKeyService, resolveJimakuApiKeyService,
runStartupBootstrapRuntimeService, runStartupBootstrapRuntimeService,
@@ -130,34 +127,31 @@ import {
setVisibleOverlayVisibleService, setVisibleOverlayVisibleService,
shouldAutoInitializeOverlayRuntimeFromConfigService, shouldAutoInitializeOverlayRuntimeFromConfigService,
shouldBindVisibleOverlayToMpvSubVisibilityService, shouldBindVisibleOverlayToMpvSubVisibilityService,
shortcutMatchesInputForLocalFallback,
showMpvOsdRuntimeService, showMpvOsdRuntimeService,
startAppLifecycleService, startAppLifecycleService,
syncInvisibleOverlayMousePassthroughService, syncInvisibleOverlayMousePassthroughService,
syncOverlayShortcutsRuntimeService,
tokenizeSubtitleService, tokenizeSubtitleService,
triggerFieldGroupingService, triggerFieldGroupingService,
unregisterOverlayShortcutsRuntimeService,
updateCurrentMediaPathService, updateCurrentMediaPathService,
updateInvisibleOverlayVisibilityService, updateInvisibleOverlayVisibilityService,
updateLastCardFromClipboardService, updateLastCardFromClipboardService,
updateVisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService,
} from "./core/services"; } from "./core/services";
import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler";
import { import {
runAppReadyRuntimeService, runAppReadyRuntimeService,
} from "./core/services/startup-service"; } from "./core/services/startup-service";
import type { AppReadyRuntimeDeps } from "./core/services/startup-service";
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service"; import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
import { import {
createAppLifecycleRuntimeDeps as createAppLifecycleRuntimeDepsBuilder, createAppLifecycleRuntimeDeps,
createAppReadyRuntimeDeps as createAppReadyRuntimeDepsBuilder, createAppReadyRuntimeDeps,
} from "./main/app-lifecycle"; } from "./main/app-lifecycle";
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command"; import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
import { import {
registerIpcRuntimeServices, registerIpcRuntimeServices,
} from "./main/ipc-runtime"; } from "./main/ipc-runtime";
import { handleCliCommandRuntimeService } from "./main/cli-runtime"; import {
handleCliCommandRuntimeServiceWithContext,
} from "./main/cli-runtime";
import { import {
runSubsyncManualFromIpcRuntime, runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime, triggerSubsyncFromConfigRuntime,
@@ -166,6 +160,9 @@ import {
createOverlayModalRuntimeService, createOverlayModalRuntimeService,
type OverlayHostedModal, type OverlayHostedModal,
} from "./main/overlay-runtime"; } from "./main/overlay-runtime";
import {
createOverlayShortcutsRuntimeService,
} from "./main/overlay-shortcuts-runtime";
import { import {
applyStartupState, applyStartupState,
createAppState, createAppState,
@@ -177,9 +174,6 @@ import {
DEFAULT_KEYBINDINGS, DEFAULT_KEYBINDINGS,
generateConfigTemplate, generateConfigTemplate,
} from "./config"; } from "./config";
import type {
AppLifecycleDepsRuntimeOptions,
} from "./core/services/app-lifecycle-service";
if (process.platform === "linux") { if (process.platform === "linux") {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
@@ -290,6 +284,44 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(), mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT, 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 { function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null {
return appState.fieldGroupingResolver; return appState.fieldGroupingResolver;
@@ -530,79 +562,34 @@ const startupState = runStartupBootstrapRuntimeService(
startAppLifecycle: (args: CliArgs) => { startAppLifecycle: (args: CliArgs) => {
startAppLifecycleService( startAppLifecycleService(
args, args,
createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()), createAppLifecycleDepsRuntimeService(
); createAppLifecycleRuntimeDeps({
},
}),
);
applyStartupState(appState, startupState);
function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
return createAppLifecycleRuntimeDepsBuilder({
app, app,
platform: process.platform, platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv), parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: ( handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
nextArgs: CliArgs, handleCliCommand(nextArgs, source),
source: CliCommandSource,
) => handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(), logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => { onReady: async () => {
await runAppReadyRuntimeService(createAppReadyRuntimeDeps()); 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(), loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => { resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(
getResolvedConfig(),
DEFAULT_KEYBINDINGS,
);
}, },
createMpvClient: () => { createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
}, },
reloadConfig: () => { reloadConfig: () => {
configService.reloadConfig(); configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); appLogger.logInfo(
`Using config file: ${configService.getConfigPath()}`,
);
}, },
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(), getConfigWarnings: () => configService.getWarnings(),
@@ -650,26 +637,71 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
shouldAutoInitializeOverlayRuntimeFromConfig(), shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), 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();
},
}),
),
);
},
}),
);
applyStartupState(appState, startupState);
function handleCliCommand( function handleCliCommand(
args: CliArgs, args: CliArgs,
source: CliCommandSource = "initial", source: CliCommandSource = "initial",
): void { ): void {
handleCliCommandRuntimeService(args, source, { handleCliCommandRuntimeServiceWithContext(args, source, {
mpv: {
getSocketPath: () => appState.mpvSocketPath, getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => { setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath; appState.mpvSocketPath = socketPath;
}, },
getClient: () => appState.mpvClient, getClient: () => appState.mpvClient,
showOsd: (text: string) => showMpvOsd(text), showOsd: (text: string) => showMpvOsd(text),
}, texthookerService,
texthooker: { getTexthookerPort: () => appState.texthookerPort,
service: texthookerService, setTexthookerPort: (port: number) => {
getPort: () => appState.texthookerPort,
setPort: (port: number) => {
appState.texthookerPort = port; appState.texthookerPort = port;
}, },
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
@@ -678,16 +710,12 @@ function handleCliCommand(
console.error(`Failed to open browser for texthooker URL: ${url}`, error); console.error(`Failed to open browser for texthooker URL: ${url}`, error);
}); });
}, },
}, isOverlayInitialized: () => appState.overlayRuntimeInitialized,
overlay: { initializeOverlay: () => initializeOverlayRuntime(),
isInitialized: () => appState.overlayRuntimeInitialized, toggleVisibleOverlay: () => toggleVisibleOverlay(),
initialize: () => initializeOverlayRuntime(), toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
toggleVisible: () => toggleVisibleOverlay(), setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible),
toggleInvisible: () => toggleInvisibleOverlay(), setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible),
setVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
},
mining: {
copyCurrentSubtitle: () => copyCurrentSubtitle(), copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(), mineSentenceCard: () => mineSentenceCard(),
@@ -697,17 +725,12 @@ function handleCliCommand(
triggerFieldGrouping: () => triggerFieldGrouping(), triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
},
ui: {
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(), cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
}, stopApp: () => app.quit(),
app: {
stop: () => app.quit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()), hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
},
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
log: (message: string) => { log: (message: string) => {
@@ -875,7 +898,7 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow {
? overlayManager.getVisibleOverlayVisible() ? overlayManager.getVisibleOverlayVisible()
: overlayManager.getInvisibleOverlayVisible(), : overlayManager.getInvisibleOverlayVisible(),
tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback: (input) =>
tryHandleOverlayShortcutLocalFallback(input), overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => { onWindowClosed: (windowKind) => {
if (windowKind === "visible") { if (windowKind === "visible") {
overlayManager.setMainWindow(null); overlayManager.setMainWindow(null);
@@ -932,9 +955,7 @@ function initializeOverlayRuntime(): void {
updateInvisibleOverlayVisibility(); updateInvisibleOverlayVisibility();
}, },
getOverlayWindows: () => getOverlayWindows(), getOverlayWindows: () => getOverlayWindows(),
syncOverlayShortcuts: () => { syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
syncOverlayShortcuts();
},
setWindowTracker: (tracker) => { setWindowTracker: (tracker) => {
appState.windowTracker = tracker; appState.windowTracker = tracker;
}, },
@@ -980,46 +1001,6 @@ function registerGlobalShortcuts(): void {
function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); } 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 { function cycleSecondarySubMode(): void {
cycleSecondarySubModeService( cycleSecondarySubModeService(
{ {
@@ -1201,42 +1182,18 @@ function handleMineSentenceDigit(count: number): void {
} }
function registerOverlayShortcuts(): void { function registerOverlayShortcuts(): void {
appState.shortcutsRegistered = registerOverlayShortcutsService( overlayShortcutsRuntime.registerOverlayShortcuts();
getConfiguredShortcuts(),
getOverlayShortcutRuntimeHandlers().overlayHandlers,
);
}
function getOverlayShortcutLifecycleDeps() {
return {
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getOverlayHandlers: () => getOverlayShortcutRuntimeHandlers().overlayHandlers,
cancelPendingMultiCopy: () => cancelPendingMultiCopy(),
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultiple(),
};
} }
function unregisterOverlayShortcuts(): void { function unregisterOverlayShortcuts(): void {
appState.shortcutsRegistered = unregisterOverlayShortcutsRuntimeService( overlayShortcutsRuntime.unregisterOverlayShortcuts();
appState.shortcutsRegistered,
getOverlayShortcutLifecycleDeps(),
);
} }
function shouldOverlayShortcutsBeActive(): boolean { return appState.overlayRuntimeInitialized; }
function syncOverlayShortcuts(): void { function syncOverlayShortcuts(): void {
appState.shortcutsRegistered = syncOverlayShortcutsRuntimeService( overlayShortcutsRuntime.syncOverlayShortcuts();
shouldOverlayShortcutsBeActive(),
appState.shortcutsRegistered,
getOverlayShortcutLifecycleDeps(),
);
} }
function refreshOverlayShortcuts(): void { function refreshOverlayShortcuts(): void {
appState.shortcutsRegistered = refreshOverlayShortcutsRuntimeService( overlayShortcutsRuntime.refreshOverlayShortcuts();
shouldOverlayShortcutsBeActive(),
appState.shortcutsRegistered,
getOverlayShortcutLifecycleDeps(),
);
} }
function updateVisibleOverlayVisibility(): void { function updateVisibleOverlayVisibility(): void {

View File

@@ -2,6 +2,99 @@ import { handleCliCommandService, createCliCommandDepsRuntimeService } from "../
import type { CliArgs, CliCommandSource } from "../cli/args"; import type { CliArgs, CliCommandSource } from "../cli/args";
import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams } from "./dependencies"; import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams } from "./dependencies";
export interface CliCommandRuntimeServiceContext {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getClient: CliCommandRuntimeServiceDepsParams["mpv"]["getClient"];
showOsd: CliCommandRuntimeServiceDepsParams["mpv"]["showOsd"];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
log: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
export interface CliCommandRuntimeServiceContextHandlers {
texthookerService: CliCommandRuntimeServiceDepsParams["texthooker"]["service"];
}
function createCliCommandDepsFromContext(
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
): CliCommandRuntimeServiceDepsParams {
return {
mpv: {
getSocketPath: context.getSocketPath,
setSocketPath: context.setSocketPath,
getClient: context.getClient,
showOsd: context.showOsd,
},
texthooker: {
service: context.texthookerService,
getPort: context.getTexthookerPort,
setPort: context.setTexthookerPort,
shouldOpenBrowser: context.shouldOpenBrowser,
openInBrowser: context.openInBrowser,
},
overlay: {
isInitialized: context.isOverlayInitialized,
initialize: context.initializeOverlay,
toggleVisible: context.toggleVisibleOverlay,
toggleInvisible: context.toggleInvisibleOverlay,
setVisible: context.setVisibleOverlay,
setInvisible: context.setInvisibleOverlay,
},
mining: {
copyCurrentSubtitle: context.copyCurrentSubtitle,
startPendingMultiCopy: context.startPendingMultiCopy,
mineSentenceCard: context.mineSentenceCard,
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
triggerFieldGrouping: context.triggerFieldGrouping,
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
},
ui: {
openYomitanSettings: context.openYomitanSettings,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
},
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
},
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule,
log: context.log,
warn: context.warn,
error: context.error,
};
}
export function handleCliCommandRuntimeService( export function handleCliCommandRuntimeService(
args: CliArgs, args: CliArgs,
source: CliCommandSource, source: CliCommandSource,
@@ -13,3 +106,10 @@ export function handleCliCommandRuntimeService(
handleCliCommandService(args, source, deps); handleCliCommandService(args, source, deps);
} }
export function handleCliCommandRuntimeServiceWithContext(
args: CliArgs,
source: CliCommandSource,
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
): void {
handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context));
}

View File

@@ -0,0 +1,138 @@
import type { ConfiguredShortcuts } from "../core/utils/shortcut-config";
import {
createOverlayShortcutRuntimeHandlers,
shortcutMatchesInputForLocalFallback,
} from "../core/services";
import {
refreshOverlayShortcutsRuntimeService,
registerOverlayShortcutsService,
syncOverlayShortcutsRuntimeService,
unregisterOverlayShortcutsRuntimeService,
} from "../core/services";
import { runOverlayShortcutLocalFallback } from "../core/services/overlay-shortcut-handler";
export interface OverlayShortcutRuntimeServiceInput {
getConfiguredShortcuts: () => ConfiguredShortcuts;
getShortcutsRegistered: () => boolean;
setShortcutsRegistered: (registered: boolean) => void;
isOverlayRuntimeInitialized: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
toggleSecondarySubMode: () => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
mineSentenceMultiple: (timeoutMs: number) => void;
cancelPendingMultiCopy: () => void;
cancelPendingMineSentenceMultiple: () => void;
}
export interface OverlayShortcutsRuntimeService {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
registerOverlayShortcuts: () => void;
unregisterOverlayShortcuts: () => void;
syncOverlayShortcuts: () => void;
refreshOverlayShortcuts: () => void;
}
export function createOverlayShortcutsRuntimeService(
input: OverlayShortcutRuntimeServiceInput,
): OverlayShortcutsRuntimeService {
const handlers = createOverlayShortcutRuntimeHandlers({
showMpvOsd: (text: string) => input.showMpvOsd(text),
openRuntimeOptions: () => {
input.openRuntimeOptionsPalette();
},
openJimaku: () => {
input.openJimaku();
},
markAudioCard: () => {
return input.markAudioCard();
},
copySubtitleMultiple: (timeoutMs: number) => {
input.copySubtitleMultiple(timeoutMs);
},
copySubtitle: () => {
input.copySubtitle();
},
toggleSecondarySub: () => {
input.toggleSecondarySubMode();
},
updateLastCardFromClipboard: () => {
return input.updateLastCardFromClipboard();
},
triggerFieldGrouping: () => {
return input.triggerFieldGrouping();
},
triggerSubsync: () => {
return input.triggerSubsyncFromConfig();
},
mineSentence: () => {
return input.mineSentenceCard();
},
mineSentenceMultiple: (timeoutMs: number) => {
input.mineSentenceMultiple(timeoutMs);
},
});
const getShortcutLifecycleDeps = () => {
return {
getConfiguredShortcuts: () => input.getConfiguredShortcuts(),
getOverlayHandlers: () => handlers.overlayHandlers,
cancelPendingMultiCopy: () => input.cancelPendingMultiCopy(),
cancelPendingMineSentenceMultiple: () =>
input.cancelPendingMineSentenceMultiple(),
};
};
const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized();
return {
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
runOverlayShortcutLocalFallback(
inputEvent,
input.getConfiguredShortcuts(),
shortcutMatchesInputForLocalFallback,
handlers.fallbackHandlers,
),
registerOverlayShortcuts: () => {
input.setShortcutsRegistered(
registerOverlayShortcutsService(
input.getConfiguredShortcuts(),
handlers.overlayHandlers,
),
);
},
unregisterOverlayShortcuts: () => {
input.setShortcutsRegistered(
unregisterOverlayShortcutsRuntimeService(
input.getShortcutsRegistered(),
getShortcutLifecycleDeps(),
),
);
},
syncOverlayShortcuts: () => {
input.setShortcutsRegistered(
syncOverlayShortcutsRuntimeService(
shouldOverlayShortcutsBeActive(),
input.getShortcutsRegistered(),
getShortcutLifecycleDeps(),
),
);
},
refreshOverlayShortcuts: () => {
input.setShortcutsRegistered(
refreshOverlayShortcutsRuntimeService(
shouldOverlayShortcutsBeActive(),
input.getShortcutsRegistered(),
getShortcutLifecycleDeps(),
),
);
},
};
}