From 3f36c3d85bbbd74fbee21ab01f114c948e0d6374 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 9 Feb 2026 20:19:59 -0800 Subject: [PATCH] refactor: extract overlay visibility and ipc helper services --- src/core/services/ipc-command-service.ts | 93 +++ src/core/services/overlay-send-service.ts | 26 + .../overlay-shortcut-fallback-runner.ts | 107 ++++ .../services/overlay-visibility-service.ts | 183 ++++++ src/core/services/yomitan-settings-service.ts | 97 +++ src/core/utils/notification.ts | 55 ++ src/main.ts | 565 ++++-------------- 7 files changed, 675 insertions(+), 451 deletions(-) create mode 100644 src/core/services/ipc-command-service.ts create mode 100644 src/core/services/overlay-send-service.ts create mode 100644 src/core/services/overlay-shortcut-fallback-runner.ts create mode 100644 src/core/services/overlay-visibility-service.ts create mode 100644 src/core/services/yomitan-settings-service.ts create mode 100644 src/core/utils/notification.ts diff --git a/src/core/services/ipc-command-service.ts b/src/core/services/ipc-command-service.ts new file mode 100644 index 0000000..93cdbd2 --- /dev/null +++ b/src/core/services/ipc-command-service.ts @@ -0,0 +1,93 @@ +import { + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionValue, + SubsyncManualRunRequest, + SubsyncResult, +} from "../../types"; + +export function handleMpvCommandFromIpcService( + command: (string | number)[], + options: { + specialCommands: { + SUBSYNC_TRIGGER: string; + RUNTIME_OPTIONS_OPEN: string; + RUNTIME_OPTION_CYCLE_PREFIX: string; + REPLAY_SUBTITLE: string; + PLAY_NEXT_SUBTITLE: string; + }; + triggerSubsyncFromConfig: () => void; + openRuntimeOptionsPalette: () => void; + runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; + showMpvOsd: (text: string) => void; + mpvReplaySubtitle: () => void; + mpvPlayNextSubtitle: () => void; + mpvSendCommand: (command: (string | number)[]) => void; + isMpvConnected: () => boolean; + hasRuntimeOptionsManager: () => boolean; + }, +): void { + const first = typeof command[0] === "string" ? command[0] : ""; + if (first === options.specialCommands.SUBSYNC_TRIGGER) { + options.triggerSubsyncFromConfig(); + return; + } + + if (first === options.specialCommands.RUNTIME_OPTIONS_OPEN) { + options.openRuntimeOptionsPalette(); + return; + } + + if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) { + if (!options.hasRuntimeOptionsManager()) return; + const [, idToken, directionToken] = first.split(":"); + const id = idToken as RuntimeOptionId; + const direction: 1 | -1 = directionToken === "prev" ? -1 : 1; + const result = options.runtimeOptionsCycle(id, direction); + if (!result.ok && result.error) { + options.showMpvOsd(result.error); + } + return; + } + + if (options.isMpvConnected()) { + if (first === options.specialCommands.REPLAY_SUBTITLE) { + options.mpvReplaySubtitle(); + } else if (first === options.specialCommands.PLAY_NEXT_SUBTITLE) { + options.mpvPlayNextSubtitle(); + } else { + options.mpvSendCommand(command); + } + } +} + +export async function runSubsyncManualFromIpcService( + request: SubsyncManualRunRequest, + options: { + isSubsyncInProgress: () => boolean; + setSubsyncInProgress: (inProgress: boolean) => void; + showMpvOsd: (text: string) => void; + runWithSpinner: (task: () => Promise) => Promise; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + }, +): Promise { + if (options.isSubsyncInProgress()) { + const busy = "Subsync already running"; + options.showMpvOsd(busy); + return { ok: false, message: busy }; + } + try { + options.setSubsyncInProgress(true); + const result = await options.runWithSpinner(() => + options.runSubsyncManual(request), + ); + options.showMpvOsd(result.message); + return result; + } catch (error) { + const message = `Subsync failed: ${(error as Error).message}`; + options.showMpvOsd(message); + return { ok: false, message }; + } finally { + options.setSubsyncInProgress(false); + } +} diff --git a/src/core/services/overlay-send-service.ts b/src/core/services/overlay-send-service.ts new file mode 100644 index 0000000..4bc75d9 --- /dev/null +++ b/src/core/services/overlay-send-service.ts @@ -0,0 +1,26 @@ +import { BrowserWindow } from "electron"; + +export function sendToVisibleOverlayService(options: { + mainWindow: BrowserWindow | null; + visibleOverlayVisible: boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + channel: string; + payload?: unknown; + restoreOnModalClose?: string; + addRestoreFlag: (modal: string) => void; +}): boolean { + if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; + const wasVisible = options.visibleOverlayVisible; + if (!options.visibleOverlayVisible) { + options.setVisibleOverlayVisible(true); + } + if (!wasVisible && options.restoreOnModalClose) { + options.addRestoreFlag(options.restoreOnModalClose); + } + if (options.payload === undefined) { + options.mainWindow.webContents.send(options.channel); + } else { + options.mainWindow.webContents.send(options.channel, options.payload); + } + return true; +} diff --git a/src/core/services/overlay-shortcut-fallback-runner.ts b/src/core/services/overlay-shortcut-fallback-runner.ts new file mode 100644 index 0000000..85ef5ce --- /dev/null +++ b/src/core/services/overlay-shortcut-fallback-runner.ts @@ -0,0 +1,107 @@ +import { ConfiguredShortcuts } from "../utils/shortcut-config"; + +export interface OverlayShortcutFallbackHandlers { + openRuntimeOptions: () => void; + markAudioCard: () => void; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySub: () => void; + updateLastCardFromClipboard: () => void; + triggerFieldGrouping: () => void; + triggerSubsync: () => void; + mineSentence: () => void; + mineSentenceMultiple: (timeoutMs: number) => void; +} + +export function runOverlayShortcutLocalFallback( + input: Electron.Input, + shortcuts: ConfiguredShortcuts, + matcher: ( + input: Electron.Input, + accelerator: string, + allowWhenRegistered?: boolean, + ) => boolean, + handlers: OverlayShortcutFallbackHandlers, +): boolean { + const actions: Array<{ + accelerator: string | null | undefined; + run: () => void; + allowWhenRegistered?: boolean; + }> = [ + { + accelerator: shortcuts.openRuntimeOptions, + run: () => { + handlers.openRuntimeOptions(); + }, + }, + { + accelerator: shortcuts.markAudioCard, + run: () => { + handlers.markAudioCard(); + }, + }, + { + accelerator: shortcuts.copySubtitleMultiple, + run: () => { + handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + { + accelerator: shortcuts.copySubtitle, + run: () => { + handlers.copySubtitle(); + }, + }, + { + accelerator: shortcuts.toggleSecondarySub, + run: () => handlers.toggleSecondarySub(), + allowWhenRegistered: true, + }, + { + accelerator: shortcuts.updateLastCardFromClipboard, + run: () => { + handlers.updateLastCardFromClipboard(); + }, + }, + { + accelerator: shortcuts.triggerFieldGrouping, + run: () => { + handlers.triggerFieldGrouping(); + }, + }, + { + accelerator: shortcuts.triggerSubsync, + run: () => { + handlers.triggerSubsync(); + }, + }, + { + accelerator: shortcuts.mineSentence, + run: () => { + handlers.mineSentence(); + }, + }, + { + accelerator: shortcuts.mineSentenceMultiple, + run: () => { + handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + ]; + + for (const action of actions) { + if (!action.accelerator) continue; + if ( + matcher( + input, + action.accelerator, + action.allowWhenRegistered === true, + ) + ) { + action.run(); + return true; + } + } + + return false; +} diff --git a/src/core/services/overlay-visibility-service.ts b/src/core/services/overlay-visibility-service.ts new file mode 100644 index 0000000..894410e --- /dev/null +++ b/src/core/services/overlay-visibility-service.ts @@ -0,0 +1,183 @@ +import { BrowserWindow, screen } from "electron"; +import { BaseWindowTracker } from "../../window-trackers"; +import { WindowGeometry } from "../../types"; + +interface MpvCommandSender { + command: Array; + request_id?: number; +} + +export function updateVisibleOverlayVisibilityService(args: { + visibleOverlayVisible: boolean; + mainWindow: BrowserWindow | null; + windowTracker: BaseWindowTracker | null; + trackerNotReadyWarningShown: boolean; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: boolean; + previousSecondarySubVisibility: boolean | null; + setPreviousSecondarySubVisibility: (value: boolean | null) => void; + mpvConnected: boolean; + mpvSend: (payload: MpvCommandSender) => void; + secondarySubVisibilityRequestId: number; + updateOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; +}): void { + console.log( + "updateVisibleOverlayVisibility called, visibleOverlayVisible:", + args.visibleOverlayVisible, + ); + if (!args.mainWindow || args.mainWindow.isDestroyed()) { + console.log("mainWindow not available"); + return; + } + + if (!args.visibleOverlayVisible) { + console.log("Hiding visible overlay"); + args.mainWindow.hide(); + + if ( + args.shouldBindVisibleOverlayToMpvSubVisibility && + args.previousSecondarySubVisibility !== null && + args.mpvConnected + ) { + args.mpvSend({ + command: [ + "set_property", + "secondary-sub-visibility", + args.previousSecondarySubVisibility ? "yes" : "no", + ], + }); + args.setPreviousSecondarySubVisibility(null); + } else if (!args.shouldBindVisibleOverlayToMpvSubVisibility) { + args.setPreviousSecondarySubVisibility(null); + } + args.syncOverlayShortcuts(); + return; + } + + console.log( + "Should show visible overlay, isTracking:", + args.windowTracker?.isTracking(), + ); + + if (args.shouldBindVisibleOverlayToMpvSubVisibility && args.mpvConnected) { + args.mpvSend({ + command: ["get_property", "secondary-sub-visibility"], + request_id: args.secondarySubVisibilityRequestId, + }); + } + + if (args.windowTracker && args.windowTracker.isTracking()) { + args.setTrackerNotReadyWarningShown(false); + const geometry = args.windowTracker.getGeometry(); + console.log("Geometry:", geometry); + if (geometry) { + args.updateOverlayBounds(geometry); + } + console.log("Showing visible overlay mainWindow"); + args.ensureOverlayWindowLevel(args.mainWindow); + args.mainWindow.show(); + args.mainWindow.focus(); + args.enforceOverlayLayerOrder(); + args.syncOverlayShortcuts(); + return; + } + + if (!args.windowTracker) { + args.setTrackerNotReadyWarningShown(false); + args.ensureOverlayWindowLevel(args.mainWindow); + args.mainWindow.show(); + args.mainWindow.focus(); + args.enforceOverlayLayerOrder(); + args.syncOverlayShortcuts(); + return; + } + + if (!args.trackerNotReadyWarningShown) { + console.warn( + "Window tracker exists but is not tracking yet; using fallback bounds until tracking starts", + ); + args.setTrackerNotReadyWarningShown(true); + } + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + args.updateOverlayBounds({ + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }); + args.ensureOverlayWindowLevel(args.mainWindow); + args.mainWindow.show(); + args.mainWindow.focus(); + args.enforceOverlayLayerOrder(); + args.syncOverlayShortcuts(); +} + +export function updateInvisibleOverlayVisibilityService(args: { + invisibleWindow: BrowserWindow | null; + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; + windowTracker: BaseWindowTracker | null; + updateOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; +}): void { + if (!args.invisibleWindow || args.invisibleWindow.isDestroyed()) { + return; + } + + if (args.visibleOverlayVisible) { + args.invisibleWindow.hide(); + args.syncOverlayShortcuts(); + return; + } + + const showInvisibleWithoutFocus = (): void => { + args.ensureOverlayWindowLevel(args.invisibleWindow!); + if (typeof args.invisibleWindow!.showInactive === "function") { + args.invisibleWindow!.showInactive(); + } else { + args.invisibleWindow!.show(); + } + args.enforceOverlayLayerOrder(); + }; + + if (!args.invisibleOverlayVisible) { + args.invisibleWindow.hide(); + args.syncOverlayShortcuts(); + return; + } + + if (args.windowTracker && args.windowTracker.isTracking()) { + const geometry = args.windowTracker.getGeometry(); + if (geometry) { + args.updateOverlayBounds(geometry); + } + showInvisibleWithoutFocus(); + args.syncOverlayShortcuts(); + return; + } + + if (!args.windowTracker) { + showInvisibleWithoutFocus(); + args.syncOverlayShortcuts(); + return; + } + + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + args.updateOverlayBounds({ + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }); + showInvisibleWithoutFocus(); + args.syncOverlayShortcuts(); +} diff --git a/src/core/services/yomitan-settings-service.ts b/src/core/services/yomitan-settings-service.ts new file mode 100644 index 0000000..56bfdc0 --- /dev/null +++ b/src/core/services/yomitan-settings-service.ts @@ -0,0 +1,97 @@ +import { BrowserWindow, Extension, session } from "electron"; + +export function openYomitanSettingsWindow(options: { + yomitanExt: Extension | null; + getExistingWindow: () => BrowserWindow | null; + setWindow: (window: BrowserWindow | null) => void; +}): void { + console.log("openYomitanSettings called"); + + if (!options.yomitanExt) { + console.error( + "Yomitan extension not loaded - yomitanExt is:", + options.yomitanExt, + ); + console.error( + "This may be due to Manifest V3 service worker issues with Electron", + ); + return; + } + + const existingWindow = options.getExistingWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + console.log("Settings window already exists, focusing"); + existingWindow.focus(); + return; + } + + console.log("Creating new settings window for extension:", options.yomitanExt.id); + + const settingsWindow = new BrowserWindow({ + width: 1200, + height: 800, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + session: session.defaultSession, + }, + }); + options.setWindow(settingsWindow); + + const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`; + console.log("Loading settings URL:", settingsUrl); + + let loadAttempts = 0; + const maxAttempts = 3; + + const attemptLoad = (): void => { + settingsWindow + .loadURL(settingsUrl) + .then(() => { + console.log("Settings URL loaded successfully"); + }) + .catch((err: Error) => { + console.error("Failed to load settings URL:", err); + loadAttempts++; + if (loadAttempts < maxAttempts && !settingsWindow.isDestroyed()) { + console.log( + `Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`, + ); + setTimeout(attemptLoad, 500); + } + }); + }; + + attemptLoad(); + + settingsWindow.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription) => { + console.error( + "Settings page failed to load:", + errorCode, + errorDescription, + ); + }, + ); + + settingsWindow.webContents.on("did-finish-load", () => { + console.log("Settings page loaded successfully"); + }); + + setTimeout(() => { + if (!settingsWindow.isDestroyed()) { + settingsWindow.setSize( + settingsWindow.getSize()[0], + settingsWindow.getSize()[1], + ); + settingsWindow.webContents.invalidate(); + settingsWindow.show(); + } + }, 500); + + settingsWindow.on("closed", () => { + options.setWindow(null); + }); +} diff --git a/src/core/utils/notification.ts b/src/core/utils/notification.ts new file mode 100644 index 0000000..0b5f672 --- /dev/null +++ b/src/core/utils/notification.ts @@ -0,0 +1,55 @@ +import { Notification, nativeImage } from "electron"; +import * as fs from "fs"; + +export function showDesktopNotification( + title: string, + options: { body?: string; icon?: string }, +): void { + const notificationOptions: { + title: string; + body?: string; + icon?: Electron.NativeImage | string; + } = { title }; + + if (options.body) { + notificationOptions.body = options.body; + } + + if (options.icon) { + const isFilePath = + typeof options.icon === "string" && + (options.icon.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(options.icon)); + + if (isFilePath) { + if (fs.existsSync(options.icon)) { + notificationOptions.icon = options.icon; + } else { + console.warn("Notification icon file not found:", options.icon); + } + } else if ( + typeof options.icon === "string" && + options.icon.startsWith("data:image/") + ) { + const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, ""); + try { + const image = nativeImage.createFromBuffer( + Buffer.from(base64Data, "base64"), + ); + if (image.isEmpty()) { + console.warn( + "Notification icon created from base64 is empty - image format may not be supported by Electron", + ); + } else { + notificationOptions.icon = image; + } + } catch (err) { + console.error("Failed to create notification icon from base64:", err); + } + } else { + notificationOptions.icon = options.icon; + } + } + + const notification = new Notification(notificationOptions); + notification.show(); +} diff --git a/src/main.ts b/src/main.ts index 114a3b5..4ff5266 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,8 +27,6 @@ import { screen, IpcMainEvent, Extension, - Notification, - nativeImage, } from "electron"; protocol.registerSchemesAsPrivileged([ @@ -143,6 +141,18 @@ import { registerOverlayShortcutsService, unregisterOverlayShortcutsService, } from "./core/services/overlay-shortcut-service"; +import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-fallback-runner"; +import { showDesktopNotification } from "./core/utils/notification"; +import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service"; +import { + handleMpvCommandFromIpcService, + runSubsyncManualFromIpcService, +} from "./core/services/ipc-command-service"; +import { sendToVisibleOverlayService } from "./core/services/overlay-send-service"; +import { + updateInvisibleOverlayVisibilityService, + updateVisibleOverlayVisibilityService, +} from "./core/services/overlay-visibility-service"; import { ConfigService, DEFAULT_CONFIG, @@ -1976,9 +1986,6 @@ function enforceOverlayLayerOrder(): void { } function ensureExtensionCopy(sourceDir: string): string { - // Copy extension to writable location on Linux and macOS - // MV3 service workers need write access for IndexedDB/storage - // App bundles on macOS are read-only, causing service worker failures if (process.platform === "win32") { return sourceDir; } @@ -2038,8 +2045,6 @@ async function loadYomitanExtension(): Promise { } } - console.log("Yomitan search paths:", searchPaths); - console.log("Found Yomitan at:", extPath); if (!extPath) { console.error("Yomitan extension not found in any search path"); @@ -2048,7 +2053,6 @@ async function loadYomitanExtension(): Promise { } extPath = ensureExtensionCopy(extPath); - console.log("Using extension path:", extPath); if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { yomitanParserWindow.destroy(); @@ -2068,7 +2072,6 @@ async function loadYomitanExtension(): Promise { allowFileAccess: true, }); } - console.log("Yomitan extension loaded successfully:", yomitanExt.id); return yomitanExt; } catch (err) { console.error("Failed to load Yomitan extension:", (err as Error).message); @@ -2103,8 +2106,6 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { ensureOverlayWindowLevel(window); const htmlPath = path.join(__dirname, "renderer", "index.html"); - console.log(`Loading ${kind} overlay HTML from:`, htmlPath); - console.log("HTML file exists:", fs.existsSync(htmlPath)); window .loadFile(htmlPath, { @@ -2127,7 +2128,6 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { ); window.webContents.on("did-finish-load", () => { - console.log(`${kind} overlay HTML loaded successfully`); broadcastRuntimeOptionsChanged(); window.webContents.send( "overlay-debug-visualization:set", @@ -2201,7 +2201,6 @@ function initializeOverlayRuntime(): void { updateOverlayBounds(geometry); }; windowTracker.onWindowFound = (geometry: WindowGeometry) => { - console.log("MPV window found:", geometry); updateOverlayBounds(geometry); if (visibleOverlayVisible) { updateVisibleOverlayVisibility(); @@ -2211,11 +2210,9 @@ function initializeOverlayRuntime(): void { } }; windowTracker.onWindowLost = () => { - console.log("MPV window lost"); for (const window of getOverlayWindows()) { window.hide(); } - // Keep overlay shortcuts registered; tracking loss can be transient. syncOverlayShortcuts(); }; windowTracker.start(); @@ -2253,93 +2250,10 @@ function initializeOverlayRuntime(): void { } function openYomitanSettings(): void { - console.log("openYomitanSettings called"); - - if (!yomitanExt) { - console.error("Yomitan extension not loaded - yomitanExt is:", yomitanExt); - console.error( - "This may be due to Manifest V3 service worker issues with Electron", - ); - return; - } - - if (yomitanSettingsWindow && !yomitanSettingsWindow.isDestroyed()) { - console.log("Settings window already exists, focusing"); - yomitanSettingsWindow.focus(); - return; - } - - console.log("Creating new settings window for extension:", yomitanExt.id); - - yomitanSettingsWindow = new BrowserWindow({ - width: 1200, - height: 800, - show: false, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - session: session.defaultSession, - }, - }); - - const settingsUrl = `chrome-extension://${yomitanExt.id}/settings.html`; - console.log("Loading settings URL:", settingsUrl); - - let loadAttempts = 0; - const maxAttempts = 3; - - function attemptLoad(): void { - yomitanSettingsWindow! - .loadURL(settingsUrl) - .then(() => { - console.log("Settings URL loaded successfully"); - }) - .catch((err: Error) => { - console.error("Failed to load settings URL:", err); - loadAttempts++; - if ( - loadAttempts < maxAttempts && - yomitanSettingsWindow && - !yomitanSettingsWindow.isDestroyed() - ) { - console.log( - `Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`, - ); - setTimeout(attemptLoad, 500); - } - }); - } - - attemptLoad(); - - yomitanSettingsWindow.webContents.on( - "did-fail-load", - (_event, errorCode, errorDescription) => { - console.error( - "Settings page failed to load:", - errorCode, - errorDescription, - ); - }, - ); - - yomitanSettingsWindow.webContents.on("did-finish-load", () => { - console.log("Settings page loaded successfully"); - }); - - setTimeout(() => { - if (yomitanSettingsWindow && !yomitanSettingsWindow.isDestroyed()) { - yomitanSettingsWindow.setSize( - yomitanSettingsWindow.getSize()[0], - yomitanSettingsWindow.getSize()[1], - ); - yomitanSettingsWindow.webContents.invalidate(); - yomitanSettingsWindow.show(); - } - }, 500); - - yomitanSettingsWindow.on("closed", () => { - yomitanSettingsWindow = null; + openYomitanSettingsWindow({ + yomitanExt, + getExistingWindow: () => yomitanSettingsWindow, + setWindow: (window) => (yomitanSettingsWindow = window), }); } @@ -2354,112 +2268,63 @@ function registerGlobalShortcuts(): void { }); } -function getConfiguredShortcuts() { - return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); -} +function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); } function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { const shortcuts = getConfiguredShortcuts(); - const handlers: Array<{ - accelerator: string | null | undefined; - run: () => void; - allowWhenRegistered?: boolean; - }> = [ + return runOverlayShortcutLocalFallback( + input, + shortcuts, + shortcutMatchesInputForLocalFallback, { - accelerator: shortcuts.openRuntimeOptions, - run: () => { + openRuntimeOptions: () => { openRuntimeOptionsPalette(); }, - }, - { - accelerator: shortcuts.markAudioCard, - run: () => { + markAudioCard: () => { markLastCardAsAudioCard().catch((err) => { console.error("markLastCardAsAudioCard failed:", err); showMpvOsd(`Audio card failed: ${(err as Error).message}`); }); }, - }, - { - accelerator: shortcuts.copySubtitleMultiple, - run: () => { - startPendingMultiCopy(shortcuts.multiCopyTimeoutMs); + copySubtitleMultiple: (timeoutMs) => { + startPendingMultiCopy(timeoutMs); }, - }, - { - accelerator: shortcuts.copySubtitle, - run: () => { + copySubtitle: () => { copyCurrentSubtitle(); }, - }, - { - accelerator: shortcuts.toggleSecondarySub, - run: () => cycleSecondarySubMode(), - allowWhenRegistered: true, - }, - { - accelerator: shortcuts.updateLastCardFromClipboard, - run: () => { + toggleSecondarySub: () => cycleSecondarySubMode(), + updateLastCardFromClipboard: () => { updateLastCardFromClipboard().catch((err) => { console.error("updateLastCardFromClipboard failed:", err); showMpvOsd(`Update failed: ${(err as Error).message}`); }); }, - }, - { - accelerator: shortcuts.triggerFieldGrouping, - run: () => { + triggerFieldGrouping: () => { triggerFieldGrouping().catch((err) => { console.error("triggerFieldGrouping failed:", err); showMpvOsd(`Field grouping failed: ${(err as Error).message}`); }); }, - }, - { - accelerator: shortcuts.triggerSubsync, - run: () => { + triggerSubsync: () => { triggerSubsyncFromConfig().catch((err) => { console.error("triggerSubsyncFromConfig failed:", err); showMpvOsd(`Subsync failed: ${(err as Error).message}`); }); }, - }, - { - accelerator: shortcuts.mineSentence, - run: () => { + mineSentence: () => { mineSentenceCard().catch((err) => { console.error("mineSentenceCard failed:", err); showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); }); }, - }, - { - accelerator: shortcuts.mineSentenceMultiple, - run: () => { - startPendingMineSentenceMultiple(shortcuts.multiCopyTimeoutMs); + mineSentenceMultiple: (timeoutMs) => { + startPendingMineSentenceMultiple(timeoutMs); }, }, - ]; - - for (const handler of handlers) { - if (!handler.accelerator) continue; - if ( - shortcutMatchesInputForLocalFallback( - input, - handler.accelerator, - handler.allowWhenRegistered === true, - ) - ) { - handler.run(); - return true; - } - } - - return false; + ); } function cycleSecondarySubMode(): void { - // Some platforms can trigger both global and in-window handlers for one key press. const now = Date.now(); if (now - lastSecondarySubToggleAtMs < 120) { return; @@ -2610,17 +2475,13 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void { if (fileExists(extraction.path)) { fs.unlinkSync(extraction.path); } - } catch { - // Ignore cleanup failures - } + } catch {} try { const dir = path.dirname(extraction.path); if (fs.existsSync(dir)) { fs.rmdirSync(dir); } - } catch { - // Ignore cleanup failures - } + } catch {} } function buildRetimedPath(subPath: string): string { @@ -2742,7 +2603,6 @@ async function runSubsyncAuto(): Promise { return alassResult; } } catch (error) { - // Fall through to ffsubsync fallback console.warn("Auto alass sync failed, trying ffsubsync fallback:", error); } finally { if (secondaryExtraction) { @@ -2857,7 +2717,6 @@ function cancelPendingMultiCopy(): void { pendingMultiCopyTimeout = null; } - // Unregister digit and escape shortcuts for (const shortcut of multiCopyDigitShortcuts) { globalShortcut.unregister(shortcut); } @@ -2873,7 +2732,6 @@ function startPendingMultiCopy(timeoutMs: number): void { cancelPendingMultiCopy(); pendingMultiCopy = true; - // Register digit shortcuts 1-9 for (let i = 1; i <= 9; i++) { const shortcut = i.toString(); if ( @@ -2885,7 +2743,6 @@ function startPendingMultiCopy(timeoutMs: number): void { } } - // Register Escape to cancel if ( globalShortcut.register("Escape", () => { cancelPendingMultiCopy(); @@ -2895,7 +2752,6 @@ function startPendingMultiCopy(timeoutMs: number): void { multiCopyEscapeShortcut = "Escape"; } - // Set timeout pendingMultiCopyTimeout = setTimeout(() => { cancelPendingMultiCopy(); showMpvOsd("Copy timeout"); @@ -2909,7 +2765,6 @@ function handleMultiCopyDigit(count: number): void { cancelPendingMultiCopy(); - // Check if we have enough history const availableCount = Math.min(count, 200); // Max history size const blocks = subtitleTimingTracker.getRecentBlocks(availableCount); @@ -3173,155 +3028,44 @@ function refreshOverlayShortcuts(): void { } function updateVisibleOverlayVisibility(): void { - console.log( - "updateVisibleOverlayVisibility called, visibleOverlayVisible:", + updateVisibleOverlayVisibilityService({ visibleOverlayVisible, - ); - if (!mainWindow || mainWindow.isDestroyed()) { - console.log("mainWindow not available"); - return; - } - - if (!visibleOverlayVisible) { - console.log("Hiding visible overlay"); - mainWindow.hide(); - - if ( - shouldBindVisibleOverlayToMpvSubVisibility() && - previousSecondarySubVisibility !== null && - mpvClient && - mpvClient.connected - ) { - mpvClient.send({ - command: [ - "set_property", - "secondary-sub-visibility", - previousSecondarySubVisibility ? "yes" : "no", - ], - }); - previousSecondarySubVisibility = null; - } else if (!shouldBindVisibleOverlayToMpvSubVisibility()) { - previousSecondarySubVisibility = null; - } - syncOverlayShortcuts(); - } else { - console.log( - "Should show visible overlay, isTracking:", - windowTracker?.isTracking(), - ); - - if ( - shouldBindVisibleOverlayToMpvSubVisibility() && - mpvClient && - mpvClient.connected - ) { - mpvClient.send({ - command: ["get_property", "secondary-sub-visibility"], - request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, - }); - } - - if (windowTracker && windowTracker.isTracking()) { - trackerNotReadyWarningShown = false; - const geometry = windowTracker.getGeometry(); - console.log("Geometry:", geometry); - if (geometry) { - updateOverlayBounds(geometry); - } - console.log("Showing visible overlay mainWindow"); - ensureOverlayWindowLevel(mainWindow); - mainWindow.show(); - mainWindow.focus(); - enforceOverlayLayerOrder(); - syncOverlayShortcuts(); - } else if (!windowTracker) { - trackerNotReadyWarningShown = false; - ensureOverlayWindowLevel(mainWindow); - mainWindow.show(); - mainWindow.focus(); - enforceOverlayLayerOrder(); - syncOverlayShortcuts(); - } else { - if (!trackerNotReadyWarningShown) { - console.warn( - "Window tracker exists but is not tracking yet; using fallback bounds until tracking starts", - ); - trackerNotReadyWarningShown = true; - } - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const fallbackBounds = display.workArea; - updateOverlayBounds({ - x: fallbackBounds.x, - y: fallbackBounds.y, - width: fallbackBounds.width, - height: fallbackBounds.height, - }); - ensureOverlayWindowLevel(mainWindow); - mainWindow.show(); - mainWindow.focus(); - enforceOverlayLayerOrder(); - syncOverlayShortcuts(); - } - } + mainWindow, + windowTracker, + trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown) => { + trackerNotReadyWarningShown = shown; + }, + shouldBindVisibleOverlayToMpvSubVisibility: + shouldBindVisibleOverlayToMpvSubVisibility(), + previousSecondarySubVisibility, + setPreviousSecondarySubVisibility: (value) => { + previousSecondarySubVisibility = value; + }, + mpvConnected: Boolean(mpvClient && mpvClient.connected), + mpvSend: (payload) => { + if (!mpvClient) return; + mpvClient.send(payload); + }, + secondarySubVisibilityRequestId: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => syncOverlayShortcuts(), + }); } function updateInvisibleOverlayVisibility(): void { - if (!invisibleWindow || invisibleWindow.isDestroyed()) { - return; - } - - // When the visible overlay is shown, keep the invisible layer hidden to - // avoid it intercepting or rerouting pointer interactions. - if (visibleOverlayVisible) { - invisibleWindow.hide(); - syncOverlayShortcuts(); - return; - } - - const showInvisibleWithoutFocus = (): void => { - ensureOverlayWindowLevel(invisibleWindow!); - if (typeof invisibleWindow!.showInactive === "function") { - invisibleWindow!.showInactive(); - } else { - invisibleWindow!.show(); - } - enforceOverlayLayerOrder(); - }; - - if (!invisibleOverlayVisible) { - invisibleWindow.hide(); - syncOverlayShortcuts(); - return; - } - - if (windowTracker && windowTracker.isTracking()) { - const geometry = windowTracker.getGeometry(); - if (geometry) { - updateOverlayBounds(geometry); - } - showInvisibleWithoutFocus(); - syncOverlayShortcuts(); - return; - } - - if (!windowTracker) { - showInvisibleWithoutFocus(); - syncOverlayShortcuts(); - return; - } - - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const fallbackBounds = display.workArea; - updateOverlayBounds({ - x: fallbackBounds.x, - y: fallbackBounds.y, - width: fallbackBounds.width, - height: fallbackBounds.height, + updateInvisibleOverlayVisibilityService({ + invisibleWindow, + visibleOverlayVisible, + invisibleOverlayVisible, + windowTracker, + updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => syncOverlayShortcuts(), }); - showInvisibleWithoutFocus(); - syncOverlayShortcuts(); } function syncInvisibleOverlayMousePassthrough(): void { @@ -3378,62 +3122,46 @@ function handleOverlayModalClosed(modal: OverlayHostedModal): void { } function handleMpvCommandFromIpc(command: (string | number)[]): void { - const first = typeof command[0] === "string" ? command[0] : ""; - if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) { - triggerSubsyncFromConfig(); - return; - } - - if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) { - openRuntimeOptionsPalette(); - return; - } - - if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { - if (!runtimeOptionsManager) return; - const [, idToken, directionToken] = first.split(":"); - const id = idToken as RuntimeOptionId; - const direction: 1 | -1 = directionToken === "prev" ? -1 : 1; - const result = applyRuntimeOptionResult( - runtimeOptionsManager.cycleOption(id, direction), - ); - if (!result.ok && result.error) { - showMpvOsd(result.error); - } - return; - } - - if (mpvClient && mpvClient.connected) { - if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) { - mpvClient.replayCurrentSubtitle(); - } else if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) { - mpvClient.playNextSubtitle(); - } else { - mpvClient.send({ command }); - } - } + handleMpvCommandFromIpcService(command, { + specialCommands: SPECIAL_COMMANDS, + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + runtimeOptionsCycle: (id, direction) => { + if (!runtimeOptionsManager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return applyRuntimeOptionResult( + runtimeOptionsManager.cycleOption(id, direction), + ); + }, + showMpvOsd: (text) => showMpvOsd(text), + mpvReplaySubtitle: () => { + if (mpvClient) mpvClient.replayCurrentSubtitle(); + }, + mpvPlayNextSubtitle: () => { + if (mpvClient) mpvClient.playNextSubtitle(); + }, + mpvSendCommand: (rawCommand) => { + if (!mpvClient) return; + mpvClient.send({ command: rawCommand }); + }, + isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), + hasRuntimeOptionsManager: () => runtimeOptionsManager !== null, + }); } async function runSubsyncManualFromIpc( request: SubsyncManualRunRequest, ): Promise { - if (subsyncInProgress) { - const busy = "Subsync already running"; - showMpvOsd(busy); - return { ok: false, message: busy }; - } - try { - subsyncInProgress = true; - const result = await runWithSubsyncSpinner(() => runSubsyncManual(request)); - showMpvOsd(result.message); - return result; - } catch (error) { - const message = `Subsync failed: ${(error as Error).message}`; - showMpvOsd(message); - return { ok: false, message }; - } finally { - subsyncInProgress = false; - } + return runSubsyncManualFromIpcService(request, { + isSubsyncInProgress: () => subsyncInProgress, + setSubsyncInProgress: (inProgress) => { + subsyncInProgress = inProgress; + }, + showMpvOsd: (text) => showMpvOsd(text), + runWithSpinner: (task) => runWithSubsyncSpinner(task), + runSubsyncManual: (subsyncRequest) => runSubsyncManual(subsyncRequest), + }); } registerIpcHandlersService({ @@ -3521,8 +3249,6 @@ function createFieldGroupingCallback() { fieldGroupingResolver = null; resolve(choice); - // Treat the visible overlay as a temporary modal host and then restore - // the user's previous overlay visibility state. if (!previousVisibleOverlay && visibleOverlayVisible) { setVisibleOverlayVisible(false); } @@ -3532,7 +3258,6 @@ function createFieldGroupingCallback() { }; fieldGroupingResolver = finish; - // Manual Kiku flow is rendered only in the visible overlay window. if (!sendToVisibleOverlay("kiku:field-grouping-request", data)) { finish({ keepNoteId: 0, @@ -3561,78 +3286,16 @@ function sendToVisibleOverlay( payload?: unknown, options?: { restoreOnModalClose?: OverlayHostedModal }, ): boolean { - if (!mainWindow || mainWindow.isDestroyed()) return false; - const wasVisible = visibleOverlayVisible; - if (!visibleOverlayVisible) { - setVisibleOverlayVisible(true); - } - if (!wasVisible && options?.restoreOnModalClose) { - restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose); - } - if (payload === undefined) { - mainWindow.webContents.send(channel); - } else { - mainWindow.webContents.send(channel, payload); - } - return true; -} - -function showDesktopNotification( - title: string, - options: { body?: string; icon?: string }, -): void { - const notificationOptions: { - title: string; - body?: string; - icon?: Electron.NativeImage | string; - } = { title }; - - if (options.body) { - notificationOptions.body = options.body; - } - - if (options.icon) { - // Check if it's a file path (starts with / on Linux/Mac, or drive letter on Windows) - const isFilePath = - typeof options.icon === "string" && - (options.icon.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(options.icon)); - - if (isFilePath) { - // File path - preferred for Linux/Wayland compatibility - // Verify file exists before using - if (fs.existsSync(options.icon)) { - notificationOptions.icon = options.icon; - } else { - console.warn("Notification icon file not found:", options.icon); - } - } else if ( - typeof options.icon === "string" && - options.icon.startsWith("data:image/") - ) { - // Data URL fallback - decode to nativeImage - const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, ""); - try { - const image = nativeImage.createFromBuffer( - Buffer.from(base64Data, "base64"), - ); - if (image.isEmpty()) { - console.warn( - "Notification icon created from base64 is empty - image format may not be supported by Electron", - ); - } else { - notificationOptions.icon = image; - } - } catch (err) { - console.error("Failed to create notification icon from base64:", err); - } - } else { - // Unknown format, try to use as-is - notificationOptions.icon = options.icon; - } - } - - const notification = new Notification(notificationOptions); - notification.show(); + return sendToVisibleOverlayService({ + mainWindow, + visibleOverlayVisible, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + channel, + payload, + restoreOnModalClose: options?.restoreOnModalClose, + addRestoreFlag: (modal) => + restoreVisibleOverlayOnModalClose.add(modal as OverlayHostedModal), + }); } ipcMain.on(