diff --git a/src/core/services/anki-jimaku-runtime-service.ts b/src/core/services/anki-jimaku-runtime-service.ts new file mode 100644 index 0000000..386df8c --- /dev/null +++ b/src/core/services/anki-jimaku-runtime-service.ts @@ -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; + broadcastRuntimeOptionsChanged: () => void; + getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; + setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; + parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo; + getCurrentMediaPath: () => string | null; + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ) => Promise>; + getJimakuMaxEntryResults: () => number; + getJimakuLanguagePreference: () => JimakuLanguagePreference; + resolveJimakuApiKey: () => Promise; + isRemoteMediaPath: (mediaPath: string) => boolean; + downloadToFile: ( + url: string, + destPath: string, + headers: Record, + ) => 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( + "/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( + `/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"] }); + } + }, + }); +} diff --git a/src/core/services/field-grouping-service.ts b/src/core/services/field-grouping-service.ts new file mode 100644 index 0000000..431074e --- /dev/null +++ b/src/core/services/field-grouping-service.ts @@ -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 { + return async ( + data: KikuFieldGroupingRequestData, + ): Promise => { + 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); + }); + }; +} diff --git a/src/core/services/overlay-runtime-init-service.ts b/src/core/services/overlay-runtime-init-service.ts new file mode 100644 index 0000000..75b3be6 --- /dev/null +++ b/src/core/services/overlay-runtime-init-service.ts @@ -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; +}): { + 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 }; +} diff --git a/src/core/services/overlay-shortcut-runtime-service.ts b/src/core/services/overlay-shortcut-runtime-service.ts new file mode 100644 index 0000000..3a241f9 --- /dev/null +++ b/src/core/services/overlay-shortcut-runtime-service.ts @@ -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; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySub: () => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsync: () => Promise; + mineSentence: () => Promise; + mineSentenceMultiple: (timeoutMs: number) => void; +} + +function wrapAsync( + task: () => Promise, + 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 }; +} diff --git a/src/core/services/overlay-visibility-runtime-service.ts b/src/core/services/overlay-visibility-runtime-service.ts new file mode 100644 index 0000000..53eecbe --- /dev/null +++ b/src/core/services/overlay-visibility-runtime-service.ts @@ -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(); +} diff --git a/src/core/services/overlay-window-service.ts b/src/core/services/overlay-window-service.ts new file mode 100644 index 0000000..957e24e --- /dev/null +++ b/src/core/services/overlay-window-service.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 1b22765..b868f6d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,13 +47,11 @@ import * as fs from "fs"; import * as crypto from "crypto"; import { MecabTokenizer } from "./mecab-tokenizer"; import { mergeTokens } from "./token-merger"; -import { createWindowTracker, BaseWindowTracker } from "./window-trackers"; +import { BaseWindowTracker } from "./window-trackers"; import { Config, JimakuApiResponse, JimakuDownloadResult, - JimakuEntry, - JimakuFileEntry, JimakuMediaInfo, JimakuConfig, JimakuLanguagePreference, @@ -65,7 +63,6 @@ import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult, - KikuFieldGroupingRequestData, KikuFieldGroupingChoice, KikuMergePreviewRequest, KikuMergePreviewResponse, @@ -84,7 +81,6 @@ import { jimakuFetchJson as jimakuFetchJsonRequest, parseMediaInfo, resolveJimakuApiKey as resolveJimakuApiKeyFromConfig, - sortJimakuFiles, } from "./jimaku/utils"; import { getSubsyncConfig, @@ -122,10 +118,24 @@ import { unregisterOverlayShortcutsService, } from "./core/services/overlay-shortcut-service"; 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 { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service"; import { tokenizeSubtitleService } from "./core/services/tokenizer-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 { MpvIpcClient, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, @@ -143,7 +153,7 @@ import { updateInvisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService, } 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 { ConfigService, DEFAULT_CONFIG, @@ -1042,34 +1052,21 @@ async function tokenizeSubtitle(text: string): Promise { } function updateOverlayBounds(geometry: WindowGeometry): void { - if (!geometry) return; - for (const window of getOverlayWindows()) { - window.setBounds({ - x: geometry.x, - y: geometry.y, - width: geometry.width, - height: geometry.height, - }); - } + updateOverlayBoundsService(geometry, () => getOverlayWindows()); } function ensureOverlayWindowLevel(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); + ensureOverlayWindowLevelService(window); } function enforceOverlayLayerOrder(): void { - if (!visibleOverlayVisible || !invisibleOverlayVisible) return; - if (!mainWindow || mainWindow.isDestroyed()) return; - if (!invisibleWindow || invisibleWindow.isDestroyed()) return; - - ensureOverlayWindowLevel(mainWindow); - mainWindow.moveTop(); + enforceOverlayLayerOrderService({ + visibleOverlayVisible, + invisibleOverlayVisible, + mainWindow, + invisibleWindow, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + }); } async function loadYomitanExtension(): Promise { @@ -1092,249 +1089,118 @@ async function loadYomitanExtension(): Promise { } function createOverlayWindow(kind: "visible" | "invisible"): 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}`], + return createOverlayWindowService(kind, { + isDev, + overlayDebugVisualizationEnabled, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => + setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => + windowKind === "visible" ? visibleOverlayVisible : invisibleOverlayVisible, + tryHandleOverlayShortcutLocalFallback: (input) => + tryHandleOverlayShortcutLocalFallback(input), + onWindowClosed: (windowKind) => { + if (windowKind === "visible") { + mainWindow = null; + } else { + invisibleWindow = null; + } }, }); - - 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 { - mainWindow = createOverlayWindow("visible"); - return mainWindow; -} - -function createInvisibleWindow(): BrowserWindow { - invisibleWindow = createOverlayWindow("invisible"); - return invisibleWindow; -} +function createMainWindow(): BrowserWindow { mainWindow = createOverlayWindow("visible"); return mainWindow; } +function createInvisibleWindow(): BrowserWindow { invisibleWindow = createOverlayWindow("invisible"); return invisibleWindow; } function initializeOverlayRuntime(): void { if (overlayRuntimeInitialized) { return; } - - createMainWindow(); - createInvisibleWindow(); - invisibleOverlayVisible = getInitialInvisibleOverlayVisibility(); - registerGlobalShortcuts(); - - windowTracker = createWindowTracker(backendOverride); - if (windowTracker) { - windowTracker.onGeometryChange = (geometry: WindowGeometry) => { + const result = initializeOverlayRuntimeService({ + backendOverride, + getInitialInvisibleOverlayVisibility: () => getInitialInvisibleOverlayVisibility(), + createMainWindow: () => { + createMainWindow(); + }, + createInvisibleWindow: () => { + createInvisibleWindow(); + }, + registerGlobalShortcuts: () => { + registerGlobalShortcuts(); + }, + updateOverlayBounds: (geometry) => { updateOverlayBounds(geometry); - }; - windowTracker.onWindowFound = (geometry: WindowGeometry) => { - updateOverlayBounds(geometry); - if (visibleOverlayVisible) { - updateVisibleOverlayVisibility(); - } - if (invisibleOverlayVisible) { - updateInvisibleOverlayVisibility(); - } - }; - windowTracker.onWindowLost = () => { - for (const window of getOverlayWindows()) { - window.hide(); - } + }, + isVisibleOverlayVisible: () => visibleOverlayVisible, + isInvisibleOverlayVisible: () => invisibleOverlayVisible, + updateVisibleOverlayVisibility: () => { + updateVisibleOverlayVisibility(); + }, + updateInvisibleOverlayVisibility: () => { + updateInvisibleOverlayVisibility(); + }, + getOverlayWindows: () => getOverlayWindows(), + syncOverlayShortcuts: () => { syncOverlayShortcuts(); - }; - windowTracker.start(); - } - - const config = getResolvedConfig(); - if ( - config.ankiConnect?.enabled && - subtitleTimingTracker && - mpvClient && - runtimeOptionsManager - ) { - const effectiveAnkiConfig = - runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect); - ankiIntegration = new AnkiIntegration( - effectiveAnkiConfig, - subtitleTimingTracker, - mpvClient, - (text: string) => { - if (mpvClient) { - mpvClient.send({ - command: ["show-text", text, "3000"], - }); - } - }, - showDesktopNotification, - createFieldGroupingCallback(), - ); - ankiIntegration.start(); - } - + }, + setWindowTracker: (tracker) => { + windowTracker = tracker; + }, + getResolvedConfig: () => getResolvedConfig(), + getSubtitleTimingTracker: () => subtitleTimingTracker, + getMpvClient: () => mpvClient, + getRuntimeOptionsManager: () => runtimeOptionsManager, + setAnkiIntegration: (integration) => { + ankiIntegration = integration as AnkiIntegration | null; + }, + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + }); + invisibleOverlayVisible = result.invisibleOverlayVisible; overlayRuntimeInitialized = true; - updateVisibleOverlayVisibility(); - updateInvisibleOverlayVisibility(); } -function openYomitanSettings(): void { - openYomitanSettingsWindow({ - yomitanExt, - getExistingWindow: () => yomitanSettingsWindow, - setWindow: (window) => (yomitanSettingsWindow = window), - }); -} - -function registerGlobalShortcuts(): void { - registerGlobalShortcutsService({ - shortcuts: getConfiguredShortcuts(), - onToggleVisibleOverlay: () => toggleVisibleOverlay(), - onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), - onOpenYomitanSettings: () => openYomitanSettings(), - isDev, - getMainWindow: () => mainWindow, - }); -} +function openYomitanSettings(): void { openYomitanSettingsWindow({ 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 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 { const shortcuts = getConfiguredShortcuts(); + const handlers = getOverlayShortcutRuntimeHandlers(); return runOverlayShortcutLocalFallback( input, shortcuts, shortcutMatchesInputForLocalFallback, - { - 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); - }, - }, + handlers.fallbackHandlers, ); } @@ -1629,54 +1495,11 @@ function handleMineSentenceDigit(count: number): void { function registerOverlayShortcuts(): void { const shortcuts = getConfiguredShortcuts(); - shortcutsRegistered = registerOverlayShortcutsService(shortcuts, { - copySubtitle: () => { - copyCurrentSubtitle(); - }, - 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"); - }, - }); + const handlers = getOverlayShortcutRuntimeHandlers(); + shortcutsRegistered = registerOverlayShortcutsService( + shortcuts, + handlers.overlayHandlers, + ); } function unregisterOverlayShortcuts(): void { @@ -1690,22 +1513,9 @@ function unregisterOverlayShortcuts(): void { shortcutsRegistered = false; } -function shouldOverlayShortcutsBeActive(): boolean { - return overlayRuntimeInitialized; -} - -function syncOverlayShortcuts(): void { - if (shouldOverlayShortcutsBeActive()) { - registerOverlayShortcuts(); - } else { - unregisterOverlayShortcuts(); - } -} - -function refreshOverlayShortcuts(): void { - unregisterOverlayShortcuts(); - syncOverlayShortcuts(); -} +function shouldOverlayShortcutsBeActive(): boolean { return overlayRuntimeInitialized; } +function syncOverlayShortcuts(): void { if (shouldOverlayShortcutsBeActive()) { registerOverlayShortcuts(); } else { unregisterOverlayShortcuts(); } } +function refreshOverlayShortcuts(): void { unregisterOverlayShortcuts(); syncOverlayShortcuts(); } function updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibilityService({ @@ -1749,57 +1559,55 @@ function updateInvisibleOverlayVisibility(): void { } function syncInvisibleOverlayMousePassthrough(): void { - if (!invisibleWindow || invisibleWindow.isDestroyed()) return; - if (visibleOverlayVisible) { - invisibleWindow.setIgnoreMouseEvents(true, { forward: true }); - } else if (invisibleOverlayVisible) { - invisibleWindow.setIgnoreMouseEvents(false); - } + syncInvisibleOverlayMousePassthroughService({ + hasInvisibleWindow: () => Boolean(invisibleWindow && !invisibleWindow.isDestroyed()), + setIgnoreMouseEvents: (ignore, extra) => { + if (!invisibleWindow || invisibleWindow.isDestroyed()) return; + invisibleWindow.setIgnoreMouseEvents(ignore, extra); + }, + visibleOverlayVisible, + invisibleOverlayVisible, + }); } function setVisibleOverlayVisible(visible: boolean): void { - visibleOverlayVisible = visible; - updateVisibleOverlayVisibility(); - updateInvisibleOverlayVisibility(); - syncInvisibleOverlayMousePassthrough(); - if ( - shouldBindVisibleOverlayToMpvSubVisibility() && - mpvClient && - mpvClient.connected - ) { - mpvClient.setSubVisibility(!visible); - } + setVisibleOverlayVisibleService({ + visible, + setVisibleOverlayVisibleState: (nextVisible) => { + visibleOverlayVisible = nextVisible; + }, + updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), + setMpvSubVisibility: (mpvSubVisible) => { + if (mpvClient) { + mpvClient.setSubVisibility(mpvSubVisible); + } + }, + }); } function setInvisibleOverlayVisible(visible: boolean): void { - invisibleOverlayVisible = visible; - updateInvisibleOverlayVisibility(); - syncInvisibleOverlayMousePassthrough(); + setInvisibleOverlayVisibleService({ + visible, + setInvisibleOverlayVisibleState: (nextVisible) => { + invisibleOverlayVisible = nextVisible; + }, + updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + syncInvisibleOverlayMousePassthrough(), + }); } -function toggleVisibleOverlay(): void { - setVisibleOverlayVisible(!visibleOverlayVisible); -} - -function toggleInvisibleOverlay(): void { - 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 toggleVisibleOverlay(): void { setVisibleOverlayVisible(!visibleOverlayVisible); } +function toggleInvisibleOverlay(): void { 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 { handleMpvCommandFromIpcService(command, { @@ -1915,172 +1723,51 @@ registerIpcHandlersService({ * Supports both file paths (preferred on Linux/Wayland) and data URLs (fallback). */ function createFieldGroupingCallback() { - return async ( - data: KikuFieldGroupingRequestData, - ): Promise => { - 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, + return createFieldGroupingCallbackService({ + getVisibleOverlayVisible: () => visibleOverlayVisible, + getInvisibleOverlayVisible: () => invisibleOverlayVisible, setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - channel, - payload, - restoreOnModalClose: options?.restoreOnModalClose, - addRestoreFlag: (modal) => - restoreVisibleOverlayOnModalClose.add(modal as OverlayHostedModal), + setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), + getResolver: () => fieldGroupingResolver, + setResolver: (resolver) => { + fieldGroupingResolver = resolver; + }, + sendRequestToVisibleOverlay: (data) => + sendToVisibleOverlay("kiku:field-grouping-request", data), }); } -registerAnkiJimakuIpcHandlers({ - setAnkiConnectEnabled: (enabled) => { +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) }); } + +registerAnkiJimakuIpcRuntimeService({ + patchAnkiConnectEnabled: (enabled) => { configService.patchRawConfig({ ankiConnect: { 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: () => { - if (subtitleTimingTracker) { - subtitleTimingTracker.cleanup(); - console.log("AnkiConnect subtitle timing history cleared"); - } + getResolvedConfig: () => getResolvedConfig(), + getRuntimeOptionsManager: () => runtimeOptionsManager, + getSubtitleTimingTracker: () => subtitleTimingTracker, + getMpvClient: () => mpvClient, + getAnkiIntegration: () => ankiIntegration, + setAnkiIntegration: (integration) => { + ankiIntegration = integration; }, - respondFieldGrouping: (choice) => { - if (fieldGroupingResolver) { - fieldGroupingResolver(choice); - fieldGroupingResolver = null; - } + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + getFieldGroupingResolver: () => fieldGroupingResolver, + setFieldGroupingResolver: (resolver) => { + fieldGroupingResolver = resolver; }, - buildKikuMergePreview: async (request) => { - 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( - "/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( - `/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(), + parseMediaInfo: (mediaPath) => parseMediaInfo(mediaPath), getCurrentMediaPath: () => currentMediaPath, + jimakuFetchJson: (endpoint, query) => jimakuFetchJson(endpoint, query), + getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), + getJimakuLanguagePreference: () => getJimakuLanguagePreference(), + resolveJimakuApiKey: () => resolveJimakuApiKey(), isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), downloadToFile: (url, destPath, headers) => downloadToFile(url, destPath, headers), - onDownloadedSubtitle: (pathToSubtitle) => { - if (mpvClient && mpvClient.connected) { - mpvClient.send({ command: ["sub-add", pathToSubtitle, "select"] }); - } - }, });