refactor: extract overlay visibility and ipc helper services

This commit is contained in:
2026-02-09 20:19:59 -08:00
parent 44d854b3bf
commit 0ff1b0092e
7 changed files with 675 additions and 451 deletions

View File

@@ -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<SubsyncResult>) => Promise<SubsyncResult>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
},
): Promise<SubsyncResult> {
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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,183 @@
import { BrowserWindow, screen } from "electron";
import { BaseWindowTracker } from "../../window-trackers";
import { WindowGeometry } from "../../types";
interface MpvCommandSender {
command: Array<string | number>;
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();
}

View File

@@ -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);
});
}

View File

@@ -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();
}

View File

@@ -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<Extension | null> {
}
}
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<Extension | null> {
}
extPath = ensureExtensionCopy(extPath);
console.log("Using extension path:", extPath);
if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) {
yomitanParserWindow.destroy();
@@ -2068,7 +2072,6 @@ async function loadYomitanExtension(): Promise<Extension | null> {
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<SubsyncResult> {
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<SubsyncResult> {
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(