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