refactor: extract overlay runtime and anki/jimaku orchestration

This commit is contained in:
2026-02-09 22:02:18 -08:00
parent f0b6dfba92
commit 3f63b3da47
7 changed files with 823 additions and 508 deletions

View File

@@ -0,0 +1,169 @@
import { AnkiIntegration } from "../../anki-integration";
import {
AnkiConnectConfig,
JimakuApiResponse,
JimakuEntry,
JimakuFileEntry,
JimakuLanguagePreference,
JimakuMediaInfo,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from "../../types";
import { sortJimakuFiles } from "../../jimaku/utils";
import { registerAnkiJimakuIpcHandlers } from "./anki-jimaku-ipc-service";
interface MpvClientLike {
connected: boolean;
send: (payload: { command: string[] }) => void;
}
interface RuntimeOptionsManagerLike {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
}
interface SubtitleTimingTrackerLike {
cleanup: () => void;
}
export function registerAnkiJimakuIpcRuntimeService(options: {
patchAnkiConnectEnabled: (enabled: boolean) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
broadcastRuntimeOptionsChanged: () => void;
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
getCurrentMediaPath: () => string | null;
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>;
isRemoteMediaPath: (mediaPath: string) => boolean;
downloadToFile: (
url: string,
destPath: string,
headers: Record<string, string>,
) => Promise<{ ok: true; path: string } | { ok: false; error: { error: string; code?: number; retryAfter?: number } }>;
}): void {
registerAnkiJimakuIpcHandlers({
setAnkiConnectEnabled: (enabled) => {
options.patchAnkiConnectEnabled(enabled);
const config = options.getResolvedConfig();
const subtitleTimingTracker = options.getSubtitleTimingTracker();
const mpvClient = options.getMpvClient();
const ankiIntegration = options.getAnkiIntegration();
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
const runtimeOptionsManager = options.getRuntimeOptionsManager();
const effectiveAnkiConfig = runtimeOptionsManager
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect)
: config.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig as never,
subtitleTimingTracker as never,
mpvClient as never,
(text: string) => {
if (mpvClient) {
mpvClient.send({
command: ["show-text", text, "3000"],
});
}
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
);
integration.start();
options.setAnkiIntegration(integration);
console.log("AnkiConnect integration enabled");
} else if (!enabled && ankiIntegration) {
ankiIntegration.destroy();
options.setAnkiIntegration(null);
console.log("AnkiConnect integration disabled");
}
options.broadcastRuntimeOptionsChanged();
},
clearAnkiHistory: () => {
const subtitleTimingTracker = options.getSubtitleTimingTracker();
if (subtitleTimingTracker) {
subtitleTimingTracker.cleanup();
console.log("AnkiConnect subtitle timing history cleared");
}
},
respondFieldGrouping: (choice) => {
const resolver = options.getFieldGroupingResolver();
if (resolver) {
resolver(choice);
options.setFieldGroupingResolver(null);
}
},
buildKikuMergePreview: async (request) => {
const integration = options.getAnkiIntegration();
if (!integration) {
return { ok: false, error: "AnkiConnect integration not enabled" };
}
return integration.buildFieldGroupingPreview(
request.keepNoteId,
request.deleteNoteId,
request.deleteDuplicate,
);
},
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
searchJimakuEntries: async (query) => {
console.log(`[jimaku] search-entries query: "${query.query}"`);
const response = await options.jimakuFetchJson<JimakuEntry[]>(
"/api/entries/search",
{
anime: true,
query: query.query,
},
);
if (!response.ok) return response;
const maxResults = options.getJimakuMaxEntryResults();
console.log(
`[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`,
);
return { ok: true, data: response.data.slice(0, maxResults) };
},
listJimakuFiles: async (query) => {
console.log(
`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`,
);
const response = await options.jimakuFetchJson<JimakuFileEntry[]>(
`/api/entries/${query.entryId}/files`,
{
episode: query.episode ?? undefined,
},
);
if (!response.ok) return response;
const sorted = sortJimakuFiles(
response.data,
options.getJimakuLanguagePreference(),
);
console.log(`[jimaku] list-files returned ${sorted.length} files`);
return { ok: true, data: sorted };
},
resolveJimakuApiKey: () => options.resolveJimakuApiKey(),
getCurrentMediaPath: () => options.getCurrentMediaPath(),
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) =>
options.downloadToFile(url, destPath, headers),
onDownloadedSubtitle: (pathToSubtitle) => {
const mpvClient = options.getMpvClient();
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ["sub-add", pathToSubtitle, "select"] });
}
},
});
}

View File

@@ -0,0 +1,59 @@
import {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
} from "../../types";
export function createFieldGroupingCallbackService(options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return async (
data: KikuFieldGroupingRequestData,
): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
const previousVisibleOverlay = options.getVisibleOverlayVisible();
const previousInvisibleOverlay = options.getInvisibleOverlayVisible();
let settled = false;
const finish = (choice: KikuFieldGroupingChoice): void => {
if (settled) return;
settled = true;
options.setResolver(null);
resolve(choice);
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false);
}
if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) {
options.setInvisibleOverlayVisible(previousInvisibleOverlay);
}
};
options.setResolver(finish);
if (!options.sendRequestToVisibleOverlay(data)) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
return;
}
setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
}
}, 90000);
});
};
}

View File

@@ -0,0 +1,103 @@
import { BrowserWindow } from "electron";
import { AnkiIntegration } from "../../anki-integration";
import { BaseWindowTracker, createWindowTracker } from "../../window-trackers";
import {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from "../../types";
export function initializeOverlayRuntimeService(options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
}): {
invisibleOverlayVisible: boolean;
} {
options.createMainWindow();
options.createInvisibleWindow();
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride);
options.setWindowTracker(windowTracker);
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateOverlayBounds(geometry);
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateOverlayBounds(geometry);
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
}
if (options.isInvisibleOverlayVisible()) {
options.updateInvisibleOverlayVisibility();
}
};
windowTracker.onWindowLost = () => {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
};
windowTracker.start();
}
const config = options.getResolvedConfig();
const subtitleTimingTracker = options.getSubtitleTimingTracker();
const mpvClient = options.getMpvClient();
const runtimeOptionsManager = options.getRuntimeOptionsManager();
if (
config.ankiConnect &&
subtitleTimingTracker &&
mpvClient &&
runtimeOptionsManager
) {
const effectiveAnkiConfig =
runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect);
const integration = new AnkiIntegration(
effectiveAnkiConfig,
subtitleTimingTracker as never,
mpvClient as never,
(text: string) => {
if (mpvClient && typeof mpvClient.send === "function") {
mpvClient.send({
command: ["show-text", text, "3000"],
});
}
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
);
integration.start();
options.setAnkiIntegration(integration);
}
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
return { invisibleOverlayVisible };
}

View File

@@ -0,0 +1,105 @@
import {
OverlayShortcutFallbackHandlers,
} from "./overlay-shortcut-fallback-runner";
import { OverlayShortcutHandlers } from "./overlay-shortcut-service";
export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
toggleSecondarySub: () => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsync: () => Promise<void>;
mineSentence: () => Promise<void>;
mineSentenceMultiple: (timeoutMs: number) => void;
}
function wrapAsync(
task: () => Promise<void>,
deps: OverlayShortcutRuntimeDeps,
logLabel: string,
osdLabel: string,
): () => void {
return () => {
task().catch((err) => {
console.error(`${logLabel} failed:`, err);
deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`);
});
};
}
export function createOverlayShortcutRuntimeHandlers(
deps: OverlayShortcutRuntimeDeps,
): {
overlayHandlers: OverlayShortcutHandlers;
fallbackHandlers: OverlayShortcutFallbackHandlers;
} {
const overlayHandlers: OverlayShortcutHandlers = {
copySubtitle: () => {
deps.copySubtitle();
},
copySubtitleMultiple: (timeoutMs) => {
deps.copySubtitleMultiple(timeoutMs);
},
updateLastCardFromClipboard: wrapAsync(
() => deps.updateLastCardFromClipboard(),
deps,
"updateLastCardFromClipboard",
"Update failed",
),
triggerFieldGrouping: wrapAsync(
() => deps.triggerFieldGrouping(),
deps,
"triggerFieldGrouping",
"Field grouping failed",
),
triggerSubsync: wrapAsync(
() => deps.triggerSubsync(),
deps,
"triggerSubsyncFromConfig",
"Subsync failed",
),
mineSentence: wrapAsync(
() => deps.mineSentence(),
deps,
"mineSentenceCard",
"Mine sentence failed",
),
mineSentenceMultiple: (timeoutMs) => {
deps.mineSentenceMultiple(timeoutMs);
},
toggleSecondarySub: () => deps.toggleSecondarySub(),
markAudioCard: wrapAsync(
() => deps.markAudioCard(),
deps,
"markLastCardAsAudioCard",
"Audio card failed",
),
openRuntimeOptions: () => {
deps.openRuntimeOptions();
},
openJimaku: () => {
deps.openJimaku();
},
};
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
copySubtitle: overlayHandlers.copySubtitle,
toggleSecondarySub: overlayHandlers.toggleSecondarySub,
updateLastCardFromClipboard: overlayHandlers.updateLastCardFromClipboard,
triggerFieldGrouping: overlayHandlers.triggerFieldGrouping,
triggerSubsync: overlayHandlers.triggerSubsync,
mineSentence: overlayHandlers.mineSentence,
mineSentenceMultiple: overlayHandlers.mineSentenceMultiple,
};
return { overlayHandlers, fallbackHandlers };
}

View File

@@ -0,0 +1,46 @@
export function syncInvisibleOverlayMousePassthroughService(options: {
hasInvisibleWindow: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
}): void {
if (!options.hasInvisibleWindow()) return;
if (options.visibleOverlayVisible) {
options.setIgnoreMouseEvents(true, { forward: true });
} else if (options.invisibleOverlayVisible) {
options.setIgnoreMouseEvents(false);
}
}
export function setVisibleOverlayVisibleService(options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}): void {
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
if (
options.shouldBindVisibleOverlayToMpvSubVisibility() &&
options.isMpvConnected()
) {
options.setMpvSubVisibility(!options.visible);
}
}
export function setInvisibleOverlayVisibleService(options: {
visible: boolean;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}): void {
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
}

View File

@@ -0,0 +1,146 @@
import { BrowserWindow } from "electron";
import * as path from "path";
import { WindowGeometry } from "../../types";
export type OverlayWindowKind = "visible" | "invisible";
export function updateOverlayBoundsService(
geometry: WindowGeometry,
getOverlayWindows: () => BrowserWindow[],
): void {
if (!geometry) return;
for (const window of getOverlayWindows()) {
window.setBounds({
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
});
}
}
export function ensureOverlayWindowLevelService(window: BrowserWindow): void {
if (process.platform === "darwin") {
window.setAlwaysOnTop(true, "screen-saver", 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
window.setFullScreenable(false);
return;
}
window.setAlwaysOnTop(true);
}
export function enforceOverlayLayerOrderService(options: {
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: BrowserWindow | null;
invisibleWindow: BrowserWindow | null;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
}): void {
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return;
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
options.ensureOverlayWindowLevel(options.mainWindow);
options.mainWindow.moveTop();
}
export function createOverlayWindowService(
kind: OverlayWindowKind,
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (kind: OverlayWindowKind) => void;
},
): BrowserWindow {
const window = new BrowserWindow({
show: false,
width: 800,
height: 600,
x: 0,
y: 0,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
focusable: true,
webPreferences: {
preload: path.join(__dirname, "..", "..", "preload.js"),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
additionalArguments: [`--overlay-layer=${kind}`],
},
});
options.ensureOverlayWindowLevel(window);
const htmlPath = path.join(__dirname, "..", "..", "renderer", "index.html");
window
.loadFile(htmlPath, {
query: { layer: kind === "visible" ? "visible" : "invisible" },
})
.catch((err) => {
console.error("Failed to load HTML file:", err);
});
window.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL) => {
console.error(
"Page failed to load:",
errorCode,
errorDescription,
validatedURL,
);
},
);
window.webContents.on("did-finish-load", () => {
options.onRuntimeOptionsChanged();
window.webContents.send(
"overlay-debug-visualization:set",
options.overlayDebugVisualizationEnabled,
);
});
if (kind === "visible") {
window.webContents.on("devtools-opened", () => {
options.setOverlayDebugVisualizationEnabled(true);
});
window.webContents.on("devtools-closed", () => {
options.setOverlayDebugVisualizationEnabled(false);
});
}
window.webContents.on("before-input-event", (event, input) => {
if (!options.isOverlayVisible(kind)) return;
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault();
});
window.hide();
window.on("closed", () => {
options.onWindowClosed(kind);
});
window.on("blur", () => {
if (!window.isDestroyed()) {
options.ensureOverlayWindowLevel(window);
}
});
if (options.isDev && kind === "visible") {
window.webContents.openDevTools({ mode: "detach" });
}
return window;
}

View File

@@ -47,13 +47,11 @@ import * as fs from "fs";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { MecabTokenizer } from "./mecab-tokenizer"; import { MecabTokenizer } from "./mecab-tokenizer";
import { mergeTokens } from "./token-merger"; import { mergeTokens } from "./token-merger";
import { createWindowTracker, BaseWindowTracker } from "./window-trackers"; import { BaseWindowTracker } from "./window-trackers";
import { import {
Config, Config,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadResult, JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuMediaInfo, JimakuMediaInfo,
JimakuConfig, JimakuConfig,
JimakuLanguagePreference, JimakuLanguagePreference,
@@ -65,7 +63,6 @@ import {
SubsyncManualPayload, SubsyncManualPayload,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
KikuFieldGroupingRequestData,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuMergePreviewRequest, KikuMergePreviewRequest,
KikuMergePreviewResponse, KikuMergePreviewResponse,
@@ -84,7 +81,6 @@ import {
jimakuFetchJson as jimakuFetchJsonRequest, jimakuFetchJson as jimakuFetchJsonRequest,
parseMediaInfo, parseMediaInfo,
resolveJimakuApiKey as resolveJimakuApiKeyFromConfig, resolveJimakuApiKey as resolveJimakuApiKeyFromConfig,
sortJimakuFiles,
} from "./jimaku/utils"; } from "./jimaku/utils";
import { import {
getSubsyncConfig, getSubsyncConfig,
@@ -122,10 +118,24 @@ import {
unregisterOverlayShortcutsService, unregisterOverlayShortcutsService,
} from "./core/services/overlay-shortcut-service"; } from "./core/services/overlay-shortcut-service";
import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-fallback-runner"; import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-fallback-runner";
import { createOverlayShortcutRuntimeHandlers } from "./core/services/overlay-shortcut-runtime-service";
import { showDesktopNotification } from "./core/utils/notification"; import { showDesktopNotification } from "./core/utils/notification";
import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service"; import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service";
import { tokenizeSubtitleService } from "./core/services/tokenizer-service"; import { tokenizeSubtitleService } from "./core/services/tokenizer-service";
import { loadYomitanExtensionService } from "./core/services/yomitan-extension-loader-service"; import { loadYomitanExtensionService } from "./core/services/yomitan-extension-loader-service";
import {
createOverlayWindowService,
enforceOverlayLayerOrderService,
ensureOverlayWindowLevelService,
updateOverlayBoundsService,
} from "./core/services/overlay-window-service";
import { createFieldGroupingCallbackService } from "./core/services/field-grouping-service";
import { initializeOverlayRuntimeService } from "./core/services/overlay-runtime-init-service";
import {
setInvisibleOverlayVisibleService,
setVisibleOverlayVisibleService,
syncInvisibleOverlayMousePassthroughService,
} from "./core/services/overlay-visibility-runtime-service";
import { import {
MpvIpcClient, MpvIpcClient,
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
@@ -143,7 +153,7 @@ import {
updateInvisibleOverlayVisibilityService, updateInvisibleOverlayVisibilityService,
updateVisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService,
} from "./core/services/overlay-visibility-service"; } from "./core/services/overlay-visibility-service";
import { registerAnkiJimakuIpcHandlers } from "./core/services/anki-jimaku-ipc-service"; import { registerAnkiJimakuIpcRuntimeService } from "./core/services/anki-jimaku-runtime-service";
import { import {
ConfigService, ConfigService,
DEFAULT_CONFIG, DEFAULT_CONFIG,
@@ -1042,34 +1052,21 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
} }
function updateOverlayBounds(geometry: WindowGeometry): void { function updateOverlayBounds(geometry: WindowGeometry): void {
if (!geometry) return; updateOverlayBoundsService(geometry, () => getOverlayWindows());
for (const window of getOverlayWindows()) {
window.setBounds({
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
});
}
} }
function ensureOverlayWindowLevel(window: BrowserWindow): void { function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === "darwin") { ensureOverlayWindowLevelService(window);
window.setAlwaysOnTop(true, "screen-saver", 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
window.setFullScreenable(false);
return;
}
window.setAlwaysOnTop(true);
} }
function enforceOverlayLayerOrder(): void { function enforceOverlayLayerOrder(): void {
if (!visibleOverlayVisible || !invisibleOverlayVisible) return; enforceOverlayLayerOrderService({
if (!mainWindow || mainWindow.isDestroyed()) return; visibleOverlayVisible,
if (!invisibleWindow || invisibleWindow.isDestroyed()) return; invisibleOverlayVisible,
mainWindow,
ensureOverlayWindowLevel(mainWindow); invisibleWindow,
mainWindow.moveTop(); ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
});
} }
async function loadYomitanExtension(): Promise<Extension | null> { async function loadYomitanExtension(): Promise<Extension | null> {
@@ -1092,249 +1089,118 @@ async function loadYomitanExtension(): Promise<Extension | null> {
} }
function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow {
const window = new BrowserWindow({ return createOverlayWindowService(kind, {
show: false, isDev,
width: 800, overlayDebugVisualizationEnabled,
height: 600, ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
x: 0, onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
y: 0, setOverlayDebugVisualizationEnabled: (enabled) =>
transparent: true, setOverlayDebugVisualizationEnabled(enabled),
frame: false, isOverlayVisible: (windowKind) =>
alwaysOnTop: true, windowKind === "visible" ? visibleOverlayVisible : invisibleOverlayVisible,
skipTaskbar: true, tryHandleOverlayShortcutLocalFallback: (input) =>
resizable: false, tryHandleOverlayShortcutLocalFallback(input),
hasShadow: false, onWindowClosed: (windowKind) => {
focusable: true, if (windowKind === "visible") {
webPreferences: { mainWindow = null;
preload: path.join(__dirname, "preload.js"), } else {
contextIsolation: true, invisibleWindow = null;
nodeIntegration: false, }
webSecurity: true,
additionalArguments: [`--overlay-layer=${kind}`],
}, },
}); });
ensureOverlayWindowLevel(window);
const htmlPath = path.join(__dirname, "renderer", "index.html");
window
.loadFile(htmlPath, {
query: { layer: kind === "visible" ? "visible" : "invisible" },
})
.catch((err) => {
console.error("Failed to load HTML file:", err);
});
window.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL) => {
console.error(
"Page failed to load:",
errorCode,
errorDescription,
validatedURL,
);
},
);
window.webContents.on("did-finish-load", () => {
broadcastRuntimeOptionsChanged();
window.webContents.send(
"overlay-debug-visualization:set",
overlayDebugVisualizationEnabled,
);
});
if (kind === "visible") {
window.webContents.on("devtools-opened", () => {
setOverlayDebugVisualizationEnabled(true);
});
window.webContents.on("devtools-closed", () => {
setOverlayDebugVisualizationEnabled(false);
});
}
window.webContents.on("before-input-event", (event, input) => {
const isOverlayVisible =
kind === "visible" ? visibleOverlayVisible : invisibleOverlayVisible;
if (!isOverlayVisible) return;
if (!tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault();
});
window.hide();
window.on("closed", () => {
if (kind === "visible") {
mainWindow = null;
} else {
invisibleWindow = null;
}
});
window.on("blur", () => {
if (!window.isDestroyed()) {
ensureOverlayWindowLevel(window);
}
});
if (isDev && kind === "visible") {
window.webContents.openDevTools({ mode: "detach" });
}
return window;
} }
function createMainWindow(): BrowserWindow { function createMainWindow(): BrowserWindow { mainWindow = createOverlayWindow("visible"); return mainWindow; }
mainWindow = createOverlayWindow("visible"); function createInvisibleWindow(): BrowserWindow { invisibleWindow = createOverlayWindow("invisible"); return invisibleWindow; }
return mainWindow;
}
function createInvisibleWindow(): BrowserWindow {
invisibleWindow = createOverlayWindow("invisible");
return invisibleWindow;
}
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
if (overlayRuntimeInitialized) { if (overlayRuntimeInitialized) {
return; return;
} }
const result = initializeOverlayRuntimeService({
createMainWindow(); backendOverride,
createInvisibleWindow(); getInitialInvisibleOverlayVisibility: () => getInitialInvisibleOverlayVisibility(),
invisibleOverlayVisible = getInitialInvisibleOverlayVisibility(); createMainWindow: () => {
registerGlobalShortcuts(); createMainWindow();
},
windowTracker = createWindowTracker(backendOverride); createInvisibleWindow: () => {
if (windowTracker) { createInvisibleWindow();
windowTracker.onGeometryChange = (geometry: WindowGeometry) => { },
registerGlobalShortcuts: () => {
registerGlobalShortcuts();
},
updateOverlayBounds: (geometry) => {
updateOverlayBounds(geometry); updateOverlayBounds(geometry);
}; },
windowTracker.onWindowFound = (geometry: WindowGeometry) => { isVisibleOverlayVisible: () => visibleOverlayVisible,
updateOverlayBounds(geometry); isInvisibleOverlayVisible: () => invisibleOverlayVisible,
if (visibleOverlayVisible) { updateVisibleOverlayVisibility: () => {
updateVisibleOverlayVisibility(); updateVisibleOverlayVisibility();
} },
if (invisibleOverlayVisible) { updateInvisibleOverlayVisibility: () => {
updateInvisibleOverlayVisibility(); updateInvisibleOverlayVisibility();
} },
}; getOverlayWindows: () => getOverlayWindows(),
windowTracker.onWindowLost = () => { syncOverlayShortcuts: () => {
for (const window of getOverlayWindows()) {
window.hide();
}
syncOverlayShortcuts(); syncOverlayShortcuts();
}; },
windowTracker.start(); setWindowTracker: (tracker) => {
} windowTracker = tracker;
},
const config = getResolvedConfig(); getResolvedConfig: () => getResolvedConfig(),
if ( getSubtitleTimingTracker: () => subtitleTimingTracker,
config.ankiConnect?.enabled && getMpvClient: () => mpvClient,
subtitleTimingTracker && getRuntimeOptionsManager: () => runtimeOptionsManager,
mpvClient && setAnkiIntegration: (integration) => {
runtimeOptionsManager ankiIntegration = integration as AnkiIntegration | null;
) { },
const effectiveAnkiConfig = showDesktopNotification,
runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect); createFieldGroupingCallback: () => createFieldGroupingCallback(),
ankiIntegration = new AnkiIntegration( });
effectiveAnkiConfig, invisibleOverlayVisible = result.invisibleOverlayVisible;
subtitleTimingTracker,
mpvClient,
(text: string) => {
if (mpvClient) {
mpvClient.send({
command: ["show-text", text, "3000"],
});
}
},
showDesktopNotification,
createFieldGroupingCallback(),
);
ankiIntegration.start();
}
overlayRuntimeInitialized = true; overlayRuntimeInitialized = true;
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
} }
function openYomitanSettings(): void { function openYomitanSettings(): void { openYomitanSettingsWindow({ yomitanExt, getExistingWindow: () => yomitanSettingsWindow, setWindow: (window) => (yomitanSettingsWindow = window) }); }
openYomitanSettingsWindow({ function registerGlobalShortcuts(): void { registerGlobalShortcutsService({ shortcuts: getConfiguredShortcuts(), onToggleVisibleOverlay: () => toggleVisibleOverlay(), onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), onOpenYomitanSettings: () => openYomitanSettings(), isDev, getMainWindow: () => mainWindow }); }
yomitanExt,
getExistingWindow: () => yomitanSettingsWindow,
setWindow: (window) => (yomitanSettingsWindow = window),
});
}
function registerGlobalShortcuts(): void {
registerGlobalShortcutsService({
shortcuts: getConfiguredShortcuts(),
onToggleVisibleOverlay: () => toggleVisibleOverlay(),
onToggleInvisibleOverlay: () => toggleInvisibleOverlay(),
onOpenYomitanSettings: () => openYomitanSettings(),
isDev,
getMainWindow: () => mainWindow,
});
}
function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); } function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); }
function getOverlayShortcutRuntimeHandlers() {
return createOverlayShortcutRuntimeHandlers({
showMpvOsd: (text) => showMpvOsd(text),
openRuntimeOptions: () => {
openRuntimeOptionsPalette();
},
openJimaku: () => {
sendToVisibleOverlay("jimaku:open");
},
markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
copyCurrentSubtitle();
},
toggleSecondarySub: () => cycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsync: () => triggerSubsyncFromConfig(),
mineSentence: () => mineSentenceCard(),
mineSentenceMultiple: (timeoutMs) => {
startPendingMineSentenceMultiple(timeoutMs);
},
});
}
function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean {
const shortcuts = getConfiguredShortcuts(); const shortcuts = getConfiguredShortcuts();
const handlers = getOverlayShortcutRuntimeHandlers();
return runOverlayShortcutLocalFallback( return runOverlayShortcutLocalFallback(
input, input,
shortcuts, shortcuts,
shortcutMatchesInputForLocalFallback, shortcutMatchesInputForLocalFallback,
{ handlers.fallbackHandlers,
openRuntimeOptions: () => {
openRuntimeOptionsPalette();
},
openJimaku: () => {
sendToVisibleOverlay("jimaku:open");
},
markAudioCard: () => {
markLastCardAsAudioCard().catch((err) => {
console.error("markLastCardAsAudioCard failed:", err);
showMpvOsd(`Audio card failed: ${(err as Error).message}`);
});
},
copySubtitleMultiple: (timeoutMs) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
copyCurrentSubtitle();
},
toggleSecondarySub: () => cycleSecondarySubMode(),
updateLastCardFromClipboard: () => {
updateLastCardFromClipboard().catch((err) => {
console.error("updateLastCardFromClipboard failed:", err);
showMpvOsd(`Update failed: ${(err as Error).message}`);
});
},
triggerFieldGrouping: () => {
triggerFieldGrouping().catch((err) => {
console.error("triggerFieldGrouping failed:", err);
showMpvOsd(`Field grouping failed: ${(err as Error).message}`);
});
},
triggerSubsync: () => {
triggerSubsyncFromConfig().catch((err) => {
console.error("triggerSubsyncFromConfig failed:", err);
showMpvOsd(`Subsync failed: ${(err as Error).message}`);
});
},
mineSentence: () => {
mineSentenceCard().catch((err) => {
console.error("mineSentenceCard failed:", err);
showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
});
},
mineSentenceMultiple: (timeoutMs) => {
startPendingMineSentenceMultiple(timeoutMs);
},
},
); );
} }
@@ -1629,54 +1495,11 @@ function handleMineSentenceDigit(count: number): void {
function registerOverlayShortcuts(): void { function registerOverlayShortcuts(): void {
const shortcuts = getConfiguredShortcuts(); const shortcuts = getConfiguredShortcuts();
shortcutsRegistered = registerOverlayShortcutsService(shortcuts, { const handlers = getOverlayShortcutRuntimeHandlers();
copySubtitle: () => { shortcutsRegistered = registerOverlayShortcutsService(
copyCurrentSubtitle(); shortcuts,
}, handlers.overlayHandlers,
copySubtitleMultiple: (timeoutMs) => { );
startPendingMultiCopy(timeoutMs);
},
updateLastCardFromClipboard: () => {
updateLastCardFromClipboard().catch((err) => {
console.error("updateLastCardFromClipboard failed:", err);
showMpvOsd(`Update failed: ${(err as Error).message}`);
});
},
triggerFieldGrouping: () => {
triggerFieldGrouping().catch((err) => {
console.error("triggerFieldGrouping failed:", err);
showMpvOsd(`Field grouping failed: ${(err as Error).message}`);
});
},
triggerSubsync: () => {
triggerSubsyncFromConfig().catch((err) => {
console.error("triggerSubsyncFromConfig failed:", err);
showMpvOsd(`Subsync failed: ${(err as Error).message}`);
});
},
mineSentence: () => {
mineSentenceCard().catch((err) => {
console.error("mineSentenceCard failed:", err);
showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
});
},
mineSentenceMultiple: (timeoutMs) => {
startPendingMineSentenceMultiple(timeoutMs);
},
toggleSecondarySub: () => cycleSecondarySubMode(),
markAudioCard: () => {
markLastCardAsAudioCard().catch((err) => {
console.error("markLastCardAsAudioCard failed:", err);
showMpvOsd(`Audio card failed: ${(err as Error).message}`);
});
},
openRuntimeOptions: () => {
openRuntimeOptionsPalette();
},
openJimaku: () => {
sendToVisibleOverlay("jimaku:open");
},
});
} }
function unregisterOverlayShortcuts(): void { function unregisterOverlayShortcuts(): void {
@@ -1690,22 +1513,9 @@ function unregisterOverlayShortcuts(): void {
shortcutsRegistered = false; shortcutsRegistered = false;
} }
function shouldOverlayShortcutsBeActive(): boolean { function shouldOverlayShortcutsBeActive(): boolean { return overlayRuntimeInitialized; }
return overlayRuntimeInitialized; function syncOverlayShortcuts(): void { if (shouldOverlayShortcutsBeActive()) { registerOverlayShortcuts(); } else { unregisterOverlayShortcuts(); } }
} function refreshOverlayShortcuts(): void { unregisterOverlayShortcuts(); syncOverlayShortcuts(); }
function syncOverlayShortcuts(): void {
if (shouldOverlayShortcutsBeActive()) {
registerOverlayShortcuts();
} else {
unregisterOverlayShortcuts();
}
}
function refreshOverlayShortcuts(): void {
unregisterOverlayShortcuts();
syncOverlayShortcuts();
}
function updateVisibleOverlayVisibility(): void { function updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibilityService({ updateVisibleOverlayVisibilityService({
@@ -1749,57 +1559,55 @@ function updateInvisibleOverlayVisibility(): void {
} }
function syncInvisibleOverlayMousePassthrough(): void { function syncInvisibleOverlayMousePassthrough(): void {
if (!invisibleWindow || invisibleWindow.isDestroyed()) return; syncInvisibleOverlayMousePassthroughService({
if (visibleOverlayVisible) { hasInvisibleWindow: () => Boolean(invisibleWindow && !invisibleWindow.isDestroyed()),
invisibleWindow.setIgnoreMouseEvents(true, { forward: true }); setIgnoreMouseEvents: (ignore, extra) => {
} else if (invisibleOverlayVisible) { if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(false); invisibleWindow.setIgnoreMouseEvents(ignore, extra);
} },
visibleOverlayVisible,
invisibleOverlayVisible,
});
} }
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
visibleOverlayVisible = visible; setVisibleOverlayVisibleService({
updateVisibleOverlayVisibility(); visible,
updateInvisibleOverlayVisibility(); setVisibleOverlayVisibleState: (nextVisible) => {
syncInvisibleOverlayMousePassthrough(); visibleOverlayVisible = nextVisible;
if ( },
shouldBindVisibleOverlayToMpvSubVisibility() && updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(),
mpvClient && updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(),
mpvClient.connected syncInvisibleOverlayMousePassthrough: () =>
) { syncInvisibleOverlayMousePassthrough(),
mpvClient.setSubVisibility(!visible); shouldBindVisibleOverlayToMpvSubVisibility: () =>
} shouldBindVisibleOverlayToMpvSubVisibility(),
isMpvConnected: () => Boolean(mpvClient && mpvClient.connected),
setMpvSubVisibility: (mpvSubVisible) => {
if (mpvClient) {
mpvClient.setSubVisibility(mpvSubVisible);
}
},
});
} }
function setInvisibleOverlayVisible(visible: boolean): void { function setInvisibleOverlayVisible(visible: boolean): void {
invisibleOverlayVisible = visible; setInvisibleOverlayVisibleService({
updateInvisibleOverlayVisibility(); visible,
syncInvisibleOverlayMousePassthrough(); setInvisibleOverlayVisibleState: (nextVisible) => {
invisibleOverlayVisible = nextVisible;
},
updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () =>
syncInvisibleOverlayMousePassthrough(),
});
} }
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void { setVisibleOverlayVisible(!visibleOverlayVisible); }
setVisibleOverlayVisible(!visibleOverlayVisible); function toggleInvisibleOverlay(): void { setInvisibleOverlayVisible(!invisibleOverlayVisible); }
} function setOverlayVisible(visible: boolean): void { setVisibleOverlayVisible(visible); }
function toggleOverlay(): void { toggleVisibleOverlay(); }
function toggleInvisibleOverlay(): void { function handleOverlayModalClosed(modal: OverlayHostedModal): void { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; restoreVisibleOverlayOnModalClose.delete(modal); if (restoreVisibleOverlayOnModalClose.size === 0) { setVisibleOverlayVisible(false); } }
setInvisibleOverlayVisible(!invisibleOverlayVisible);
}
function setOverlayVisible(visible: boolean): void {
setVisibleOverlayVisible(visible);
}
function toggleOverlay(): void {
toggleVisibleOverlay();
}
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal);
if (restoreVisibleOverlayOnModalClose.size === 0) {
setVisibleOverlayVisible(false);
}
}
function handleMpvCommandFromIpc(command: (string | number)[]): void { function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcService(command, { handleMpvCommandFromIpcService(command, {
@@ -1915,172 +1723,51 @@ registerIpcHandlersService({
* Supports both file paths (preferred on Linux/Wayland) and data URLs (fallback). * Supports both file paths (preferred on Linux/Wayland) and data URLs (fallback).
*/ */
function createFieldGroupingCallback() { function createFieldGroupingCallback() {
return async ( return createFieldGroupingCallbackService({
data: KikuFieldGroupingRequestData, getVisibleOverlayVisible: () => visibleOverlayVisible,
): Promise<KikuFieldGroupingChoice> => { getInvisibleOverlayVisible: () => invisibleOverlayVisible,
return new Promise((resolve) => {
const previousVisibleOverlay = visibleOverlayVisible;
const previousInvisibleOverlay = invisibleOverlayVisible;
let settled = false;
const finish = (choice: KikuFieldGroupingChoice): void => {
if (settled) return;
settled = true;
fieldGroupingResolver = null;
resolve(choice);
if (!previousVisibleOverlay && visibleOverlayVisible) {
setVisibleOverlayVisible(false);
}
if (invisibleOverlayVisible !== previousInvisibleOverlay) {
setInvisibleOverlayVisible(previousInvisibleOverlay);
}
};
fieldGroupingResolver = finish;
if (!sendToVisibleOverlay("kiku:field-grouping-request", data)) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
return;
}
setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
}
}, 90000);
});
};
}
function sendToVisibleOverlay(
channel: string,
payload?: unknown,
options?: { restoreOnModalClose?: OverlayHostedModal },
): boolean {
return sendToVisibleOverlayService({
mainWindow,
visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
channel, setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
payload, getResolver: () => fieldGroupingResolver,
restoreOnModalClose: options?.restoreOnModalClose, setResolver: (resolver) => {
addRestoreFlag: (modal) => fieldGroupingResolver = resolver;
restoreVisibleOverlayOnModalClose.add(modal as OverlayHostedModal), },
sendRequestToVisibleOverlay: (data) =>
sendToVisibleOverlay("kiku:field-grouping-request", data),
}); });
} }
registerAnkiJimakuIpcHandlers({ function sendToVisibleOverlay(channel: string, payload?: unknown, options?: { restoreOnModalClose?: OverlayHostedModal }): boolean { return sendToVisibleOverlayService({ mainWindow, visibleOverlayVisible, setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), channel, payload, restoreOnModalClose: options?.restoreOnModalClose, addRestoreFlag: (modal) => restoreVisibleOverlayOnModalClose.add(modal as OverlayHostedModal) }); }
setAnkiConnectEnabled: (enabled) => {
registerAnkiJimakuIpcRuntimeService({
patchAnkiConnectEnabled: (enabled) => {
configService.patchRawConfig({ configService.patchRawConfig({
ankiConnect: { ankiConnect: {
enabled, enabled,
}, },
}); });
const config = getResolvedConfig();
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
const effectiveAnkiConfig = runtimeOptionsManager
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(
config.ankiConnect,
)
: config.ankiConnect;
ankiIntegration = new AnkiIntegration(
effectiveAnkiConfig,
subtitleTimingTracker,
mpvClient,
(text: string) => {
if (mpvClient) {
mpvClient.send({
command: ["show-text", text, "3000"],
});
}
},
showDesktopNotification,
createFieldGroupingCallback(),
);
ankiIntegration.start();
console.log("AnkiConnect integration enabled");
} else if (!enabled && ankiIntegration) {
ankiIntegration.destroy();
ankiIntegration = null;
console.log("AnkiConnect integration disabled");
}
broadcastRuntimeOptionsChanged();
}, },
clearAnkiHistory: () => { getResolvedConfig: () => getResolvedConfig(),
if (subtitleTimingTracker) { getRuntimeOptionsManager: () => runtimeOptionsManager,
subtitleTimingTracker.cleanup(); getSubtitleTimingTracker: () => subtitleTimingTracker,
console.log("AnkiConnect subtitle timing history cleared"); getMpvClient: () => mpvClient,
} getAnkiIntegration: () => ankiIntegration,
setAnkiIntegration: (integration) => {
ankiIntegration = integration;
}, },
respondFieldGrouping: (choice) => { showDesktopNotification,
if (fieldGroupingResolver) { createFieldGroupingCallback: () => createFieldGroupingCallback(),
fieldGroupingResolver(choice); broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
fieldGroupingResolver = null; getFieldGroupingResolver: () => fieldGroupingResolver,
} setFieldGroupingResolver: (resolver) => {
fieldGroupingResolver = resolver;
}, },
buildKikuMergePreview: async (request) => { parseMediaInfo: (mediaPath) => parseMediaInfo(mediaPath),
if (!ankiIntegration) {
return { ok: false, error: "AnkiConnect integration not enabled" };
}
return ankiIntegration.buildFieldGroupingPreview(
request.keepNoteId,
request.deleteNoteId,
request.deleteDuplicate,
);
},
getJimakuMediaInfo: () => parseMediaInfo(currentMediaPath),
searchJimakuEntries: async (query) => {
console.log(`[jimaku] search-entries query: "${query.query}"`);
const response = await jimakuFetchJson<JimakuEntry[]>(
"/api/entries/search",
{
anime: true,
query: query.query,
},
);
if (!response.ok) return response;
const maxResults = getJimakuMaxEntryResults();
console.log(
`[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`,
);
return { ok: true, data: response.data.slice(0, maxResults) };
},
listJimakuFiles: async (query) => {
console.log(
`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`,
);
const response = await jimakuFetchJson<JimakuFileEntry[]>(
`/api/entries/${query.entryId}/files`,
{
episode: query.episode ?? undefined,
},
);
if (!response.ok) return response;
const sorted = sortJimakuFiles(
response.data,
getJimakuLanguagePreference(),
);
console.log(`[jimaku] list-files returned ${sorted.length} files`);
return { ok: true, data: sorted };
},
resolveJimakuApiKey: () => resolveJimakuApiKey(),
getCurrentMediaPath: () => currentMediaPath, getCurrentMediaPath: () => currentMediaPath,
jimakuFetchJson: (endpoint, query) => jimakuFetchJson(endpoint, query),
getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(),
getJimakuLanguagePreference: () => getJimakuLanguagePreference(),
resolveJimakuApiKey: () => resolveJimakuApiKey(),
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) => downloadToFile(url, destPath, headers), downloadToFile: (url, destPath, headers) => downloadToFile(url, destPath, headers),
onDownloadedSubtitle: (pathToSubtitle) => {
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ["sub-add", pathToSubtitle, "select"] });
}
},
}); });