refactor: extract overlay visibility and ipc helper services

This commit is contained in:
2026-02-09 20:19:59 -08:00
parent a1846ba23d
commit 3f36c3d85b
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, screen,
IpcMainEvent, IpcMainEvent,
Extension, Extension,
Notification,
nativeImage,
} from "electron"; } from "electron";
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
@@ -143,6 +141,18 @@ import {
registerOverlayShortcutsService, registerOverlayShortcutsService,
unregisterOverlayShortcutsService, unregisterOverlayShortcutsService,
} from "./core/services/overlay-shortcut-service"; } 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 { import {
ConfigService, ConfigService,
DEFAULT_CONFIG, DEFAULT_CONFIG,
@@ -1976,9 +1986,6 @@ function enforceOverlayLayerOrder(): void {
} }
function ensureExtensionCopy(sourceDir: string): string { 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") { if (process.platform === "win32") {
return sourceDir; 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) { if (!extPath) {
console.error("Yomitan extension not found in any search path"); console.error("Yomitan extension not found in any search path");
@@ -2048,7 +2053,6 @@ async function loadYomitanExtension(): Promise<Extension | null> {
} }
extPath = ensureExtensionCopy(extPath); extPath = ensureExtensionCopy(extPath);
console.log("Using extension path:", extPath);
if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) {
yomitanParserWindow.destroy(); yomitanParserWindow.destroy();
@@ -2068,7 +2072,6 @@ async function loadYomitanExtension(): Promise<Extension | null> {
allowFileAccess: true, allowFileAccess: true,
}); });
} }
console.log("Yomitan extension loaded successfully:", yomitanExt.id);
return yomitanExt; return yomitanExt;
} catch (err) { } catch (err) {
console.error("Failed to load Yomitan extension:", (err as Error).message); console.error("Failed to load Yomitan extension:", (err as Error).message);
@@ -2103,8 +2106,6 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow {
ensureOverlayWindowLevel(window); ensureOverlayWindowLevel(window);
const htmlPath = path.join(__dirname, "renderer", "index.html"); 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 window
.loadFile(htmlPath, { .loadFile(htmlPath, {
@@ -2127,7 +2128,6 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow {
); );
window.webContents.on("did-finish-load", () => { window.webContents.on("did-finish-load", () => {
console.log(`${kind} overlay HTML loaded successfully`);
broadcastRuntimeOptionsChanged(); broadcastRuntimeOptionsChanged();
window.webContents.send( window.webContents.send(
"overlay-debug-visualization:set", "overlay-debug-visualization:set",
@@ -2201,7 +2201,6 @@ function initializeOverlayRuntime(): void {
updateOverlayBounds(geometry); updateOverlayBounds(geometry);
}; };
windowTracker.onWindowFound = (geometry: WindowGeometry) => { windowTracker.onWindowFound = (geometry: WindowGeometry) => {
console.log("MPV window found:", geometry);
updateOverlayBounds(geometry); updateOverlayBounds(geometry);
if (visibleOverlayVisible) { if (visibleOverlayVisible) {
updateVisibleOverlayVisibility(); updateVisibleOverlayVisibility();
@@ -2211,11 +2210,9 @@ function initializeOverlayRuntime(): void {
} }
}; };
windowTracker.onWindowLost = () => { windowTracker.onWindowLost = () => {
console.log("MPV window lost");
for (const window of getOverlayWindows()) { for (const window of getOverlayWindows()) {
window.hide(); window.hide();
} }
// Keep overlay shortcuts registered; tracking loss can be transient.
syncOverlayShortcuts(); syncOverlayShortcuts();
}; };
windowTracker.start(); windowTracker.start();
@@ -2253,93 +2250,10 @@ function initializeOverlayRuntime(): void {
} }
function openYomitanSettings(): void { function openYomitanSettings(): void {
console.log("openYomitanSettings called"); openYomitanSettingsWindow({
yomitanExt,
if (!yomitanExt) { getExistingWindow: () => yomitanSettingsWindow,
console.error("Yomitan extension not loaded - yomitanExt is:", yomitanExt); setWindow: (window) => (yomitanSettingsWindow = window),
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;
}); });
} }
@@ -2354,112 +2268,63 @@ function registerGlobalShortcuts(): void {
}); });
} }
function getConfiguredShortcuts() { function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); }
return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG);
}
function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean {
const shortcuts = getConfiguredShortcuts(); const shortcuts = getConfiguredShortcuts();
const handlers: Array<{ return runOverlayShortcutLocalFallback(
accelerator: string | null | undefined; input,
run: () => void; shortcuts,
allowWhenRegistered?: boolean; shortcutMatchesInputForLocalFallback,
}> = [
{ {
accelerator: shortcuts.openRuntimeOptions, openRuntimeOptions: () => {
run: () => {
openRuntimeOptionsPalette(); openRuntimeOptionsPalette();
}, },
}, markAudioCard: () => {
{
accelerator: shortcuts.markAudioCard,
run: () => {
markLastCardAsAudioCard().catch((err) => { markLastCardAsAudioCard().catch((err) => {
console.error("markLastCardAsAudioCard failed:", err); console.error("markLastCardAsAudioCard failed:", err);
showMpvOsd(`Audio card failed: ${(err as Error).message}`); showMpvOsd(`Audio card failed: ${(err as Error).message}`);
}); });
}, },
}, copySubtitleMultiple: (timeoutMs) => {
{ startPendingMultiCopy(timeoutMs);
accelerator: shortcuts.copySubtitleMultiple,
run: () => {
startPendingMultiCopy(shortcuts.multiCopyTimeoutMs);
}, },
}, copySubtitle: () => {
{
accelerator: shortcuts.copySubtitle,
run: () => {
copyCurrentSubtitle(); copyCurrentSubtitle();
}, },
}, toggleSecondarySub: () => cycleSecondarySubMode(),
{ updateLastCardFromClipboard: () => {
accelerator: shortcuts.toggleSecondarySub,
run: () => cycleSecondarySubMode(),
allowWhenRegistered: true,
},
{
accelerator: shortcuts.updateLastCardFromClipboard,
run: () => {
updateLastCardFromClipboard().catch((err) => { updateLastCardFromClipboard().catch((err) => {
console.error("updateLastCardFromClipboard failed:", err); console.error("updateLastCardFromClipboard failed:", err);
showMpvOsd(`Update failed: ${(err as Error).message}`); showMpvOsd(`Update failed: ${(err as Error).message}`);
}); });
}, },
}, triggerFieldGrouping: () => {
{
accelerator: shortcuts.triggerFieldGrouping,
run: () => {
triggerFieldGrouping().catch((err) => { triggerFieldGrouping().catch((err) => {
console.error("triggerFieldGrouping failed:", err); console.error("triggerFieldGrouping failed:", err);
showMpvOsd(`Field grouping failed: ${(err as Error).message}`); showMpvOsd(`Field grouping failed: ${(err as Error).message}`);
}); });
}, },
}, triggerSubsync: () => {
{
accelerator: shortcuts.triggerSubsync,
run: () => {
triggerSubsyncFromConfig().catch((err) => { triggerSubsyncFromConfig().catch((err) => {
console.error("triggerSubsyncFromConfig failed:", err); console.error("triggerSubsyncFromConfig failed:", err);
showMpvOsd(`Subsync failed: ${(err as Error).message}`); showMpvOsd(`Subsync failed: ${(err as Error).message}`);
}); });
}, },
}, mineSentence: () => {
{
accelerator: shortcuts.mineSentence,
run: () => {
mineSentenceCard().catch((err) => { mineSentenceCard().catch((err) => {
console.error("mineSentenceCard failed:", err); console.error("mineSentenceCard failed:", err);
showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
}); });
}, },
}, mineSentenceMultiple: (timeoutMs) => {
{ startPendingMineSentenceMultiple(timeoutMs);
accelerator: shortcuts.mineSentenceMultiple,
run: () => {
startPendingMineSentenceMultiple(shortcuts.multiCopyTimeoutMs);
}, },
}, },
]; );
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 { function cycleSecondarySubMode(): void {
// Some platforms can trigger both global and in-window handlers for one key press.
const now = Date.now(); const now = Date.now();
if (now - lastSecondarySubToggleAtMs < 120) { if (now - lastSecondarySubToggleAtMs < 120) {
return; return;
@@ -2610,17 +2475,13 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
if (fileExists(extraction.path)) { if (fileExists(extraction.path)) {
fs.unlinkSync(extraction.path); fs.unlinkSync(extraction.path);
} }
} catch { } catch {}
// Ignore cleanup failures
}
try { try {
const dir = path.dirname(extraction.path); const dir = path.dirname(extraction.path);
if (fs.existsSync(dir)) { if (fs.existsSync(dir)) {
fs.rmdirSync(dir); fs.rmdirSync(dir);
} }
} catch { } catch {}
// Ignore cleanup failures
}
} }
function buildRetimedPath(subPath: string): string { function buildRetimedPath(subPath: string): string {
@@ -2742,7 +2603,6 @@ async function runSubsyncAuto(): Promise<SubsyncResult> {
return alassResult; return alassResult;
} }
} catch (error) { } catch (error) {
// Fall through to ffsubsync fallback
console.warn("Auto alass sync failed, trying ffsubsync fallback:", error); console.warn("Auto alass sync failed, trying ffsubsync fallback:", error);
} finally { } finally {
if (secondaryExtraction) { if (secondaryExtraction) {
@@ -2857,7 +2717,6 @@ function cancelPendingMultiCopy(): void {
pendingMultiCopyTimeout = null; pendingMultiCopyTimeout = null;
} }
// Unregister digit and escape shortcuts
for (const shortcut of multiCopyDigitShortcuts) { for (const shortcut of multiCopyDigitShortcuts) {
globalShortcut.unregister(shortcut); globalShortcut.unregister(shortcut);
} }
@@ -2873,7 +2732,6 @@ function startPendingMultiCopy(timeoutMs: number): void {
cancelPendingMultiCopy(); cancelPendingMultiCopy();
pendingMultiCopy = true; pendingMultiCopy = true;
// Register digit shortcuts 1-9
for (let i = 1; i <= 9; i++) { for (let i = 1; i <= 9; i++) {
const shortcut = i.toString(); const shortcut = i.toString();
if ( if (
@@ -2885,7 +2743,6 @@ function startPendingMultiCopy(timeoutMs: number): void {
} }
} }
// Register Escape to cancel
if ( if (
globalShortcut.register("Escape", () => { globalShortcut.register("Escape", () => {
cancelPendingMultiCopy(); cancelPendingMultiCopy();
@@ -2895,7 +2752,6 @@ function startPendingMultiCopy(timeoutMs: number): void {
multiCopyEscapeShortcut = "Escape"; multiCopyEscapeShortcut = "Escape";
} }
// Set timeout
pendingMultiCopyTimeout = setTimeout(() => { pendingMultiCopyTimeout = setTimeout(() => {
cancelPendingMultiCopy(); cancelPendingMultiCopy();
showMpvOsd("Copy timeout"); showMpvOsd("Copy timeout");
@@ -2909,7 +2765,6 @@ function handleMultiCopyDigit(count: number): void {
cancelPendingMultiCopy(); cancelPendingMultiCopy();
// Check if we have enough history
const availableCount = Math.min(count, 200); // Max history size const availableCount = Math.min(count, 200); // Max history size
const blocks = subtitleTimingTracker.getRecentBlocks(availableCount); const blocks = subtitleTimingTracker.getRecentBlocks(availableCount);
@@ -3173,155 +3028,44 @@ function refreshOverlayShortcuts(): void {
} }
function updateVisibleOverlayVisibility(): void { function updateVisibleOverlayVisibility(): void {
console.log( updateVisibleOverlayVisibilityService({
"updateVisibleOverlayVisibility called, visibleOverlayVisible:",
visibleOverlayVisible, visibleOverlayVisible,
); mainWindow,
if (!mainWindow || mainWindow.isDestroyed()) { windowTracker,
console.log("mainWindow not available"); trackerNotReadyWarningShown,
return; setTrackerNotReadyWarningShown: (shown) => {
} trackerNotReadyWarningShown = shown;
},
if (!visibleOverlayVisible) { shouldBindVisibleOverlayToMpvSubVisibility:
console.log("Hiding visible overlay"); shouldBindVisibleOverlayToMpvSubVisibility(),
mainWindow.hide(); previousSecondarySubVisibility,
setPreviousSecondarySubVisibility: (value) => {
if ( previousSecondarySubVisibility = value;
shouldBindVisibleOverlayToMpvSubVisibility() && },
previousSecondarySubVisibility !== null && mpvConnected: Boolean(mpvClient && mpvClient.connected),
mpvClient && mpvSend: (payload) => {
mpvClient.connected if (!mpvClient) return;
) { mpvClient.send(payload);
mpvClient.send({ },
command: [ secondarySubVisibilityRequestId: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
"set_property", updateOverlayBounds: (geometry) => updateOverlayBounds(geometry),
"secondary-sub-visibility", ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
previousSecondarySubVisibility ? "yes" : "no", enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(),
], syncOverlayShortcuts: () => syncOverlayShortcuts(),
}); });
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();
}
}
} }
function updateInvisibleOverlayVisibility(): void { function updateInvisibleOverlayVisibility(): void {
if (!invisibleWindow || invisibleWindow.isDestroyed()) { updateInvisibleOverlayVisibilityService({
return; invisibleWindow,
} visibleOverlayVisible,
invisibleOverlayVisible,
// When the visible overlay is shown, keep the invisible layer hidden to windowTracker,
// avoid it intercepting or rerouting pointer interactions. updateOverlayBounds: (geometry) => updateOverlayBounds(geometry),
if (visibleOverlayVisible) { ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
invisibleWindow.hide(); enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(),
syncOverlayShortcuts(); syncOverlayShortcuts: () => 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,
}); });
showInvisibleWithoutFocus();
syncOverlayShortcuts();
} }
function syncInvisibleOverlayMousePassthrough(): void { function syncInvisibleOverlayMousePassthrough(): void {
@@ -3378,62 +3122,46 @@ function handleOverlayModalClosed(modal: OverlayHostedModal): void {
} }
function handleMpvCommandFromIpc(command: (string | number)[]): void { function handleMpvCommandFromIpc(command: (string | number)[]): void {
const first = typeof command[0] === "string" ? command[0] : ""; handleMpvCommandFromIpcService(command, {
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) { specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig(); triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
return; openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
} runtimeOptionsCycle: (id, direction) => {
if (!runtimeOptionsManager) {
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) { return { ok: false, error: "Runtime options manager unavailable" };
openRuntimeOptionsPalette(); }
return; return applyRuntimeOptionResult(
} runtimeOptionsManager.cycleOption(id, direction),
);
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { },
if (!runtimeOptionsManager) return; showMpvOsd: (text) => showMpvOsd(text),
const [, idToken, directionToken] = first.split(":"); mpvReplaySubtitle: () => {
const id = idToken as RuntimeOptionId; if (mpvClient) mpvClient.replayCurrentSubtitle();
const direction: 1 | -1 = directionToken === "prev" ? -1 : 1; },
const result = applyRuntimeOptionResult( mpvPlayNextSubtitle: () => {
runtimeOptionsManager.cycleOption(id, direction), if (mpvClient) mpvClient.playNextSubtitle();
); },
if (!result.ok && result.error) { mpvSendCommand: (rawCommand) => {
showMpvOsd(result.error); if (!mpvClient) return;
} mpvClient.send({ command: rawCommand });
return; },
} isMpvConnected: () => Boolean(mpvClient && mpvClient.connected),
hasRuntimeOptionsManager: () => runtimeOptionsManager !== null,
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 });
}
}
} }
async function runSubsyncManualFromIpc( async function runSubsyncManualFromIpc(
request: SubsyncManualRunRequest, request: SubsyncManualRunRequest,
): Promise<SubsyncResult> { ): Promise<SubsyncResult> {
if (subsyncInProgress) { return runSubsyncManualFromIpcService(request, {
const busy = "Subsync already running"; isSubsyncInProgress: () => subsyncInProgress,
showMpvOsd(busy); setSubsyncInProgress: (inProgress) => {
return { ok: false, message: busy }; subsyncInProgress = inProgress;
} },
try { showMpvOsd: (text) => showMpvOsd(text),
subsyncInProgress = true; runWithSpinner: (task) => runWithSubsyncSpinner(task),
const result = await runWithSubsyncSpinner(() => runSubsyncManual(request)); runSubsyncManual: (subsyncRequest) => runSubsyncManual(subsyncRequest),
showMpvOsd(result.message); });
return result;
} catch (error) {
const message = `Subsync failed: ${(error as Error).message}`;
showMpvOsd(message);
return { ok: false, message };
} finally {
subsyncInProgress = false;
}
} }
registerIpcHandlersService({ registerIpcHandlersService({
@@ -3521,8 +3249,6 @@ function createFieldGroupingCallback() {
fieldGroupingResolver = null; fieldGroupingResolver = null;
resolve(choice); resolve(choice);
// Treat the visible overlay as a temporary modal host and then restore
// the user's previous overlay visibility state.
if (!previousVisibleOverlay && visibleOverlayVisible) { if (!previousVisibleOverlay && visibleOverlayVisible) {
setVisibleOverlayVisible(false); setVisibleOverlayVisible(false);
} }
@@ -3532,7 +3258,6 @@ function createFieldGroupingCallback() {
}; };
fieldGroupingResolver = finish; fieldGroupingResolver = finish;
// Manual Kiku flow is rendered only in the visible overlay window.
if (!sendToVisibleOverlay("kiku:field-grouping-request", data)) { if (!sendToVisibleOverlay("kiku:field-grouping-request", data)) {
finish({ finish({
keepNoteId: 0, keepNoteId: 0,
@@ -3561,78 +3286,16 @@ function sendToVisibleOverlay(
payload?: unknown, payload?: unknown,
options?: { restoreOnModalClose?: OverlayHostedModal }, options?: { restoreOnModalClose?: OverlayHostedModal },
): boolean { ): boolean {
if (!mainWindow || mainWindow.isDestroyed()) return false; return sendToVisibleOverlayService({
const wasVisible = visibleOverlayVisible; mainWindow,
if (!visibleOverlayVisible) { visibleOverlayVisible,
setVisibleOverlayVisible(true); setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
} channel,
if (!wasVisible && options?.restoreOnModalClose) { payload,
restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose); restoreOnModalClose: options?.restoreOnModalClose,
} addRestoreFlag: (modal) =>
if (payload === undefined) { restoreVisibleOverlayOnModalClose.add(modal as OverlayHostedModal),
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();
} }
ipcMain.on( ipcMain.on(