refactor state and overlay runtime helpers

This commit is contained in:
2026-02-14 15:06:20 -08:00
parent 585fea972c
commit 5a610d9d02
14 changed files with 931 additions and 514 deletions

15
src/main/cli-runtime.ts Normal file
View File

@@ -0,0 +1,15 @@
import { handleCliCommandService, createCliCommandDepsRuntimeService } from "../core/services";
import type { CliArgs, CliCommandSource } from "../cli/args";
import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams } from "./dependencies";
export function handleCliCommandRuntimeService(
args: CliArgs,
source: CliCommandSource,
params: CliCommandRuntimeServiceDepsParams,
): void {
const deps = createCliCommandDepsRuntimeService(
createCliCommandRuntimeServiceDeps(params),
);
handleCliCommandService(args, source, deps);
}

View File

@@ -8,8 +8,19 @@ import {
AnkiJimakuIpcRuntimeServiceDepsParams,
createMainIpcRuntimeServiceDeps,
MainIpcRuntimeServiceDepsParams,
createRuntimeOptionsIpcDeps,
RuntimeOptionsIpcDepsParams,
} from "./dependencies";
export interface RegisterIpcRuntimeServicesParams {
runtimeOptions: RuntimeOptionsIpcDepsParams;
mainDeps: Omit<
MainIpcRuntimeServiceDepsParams,
"setRuntimeOption" | "cycleRuntimeOption"
>;
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
}
export function registerMainIpcRuntimeServices(
params: MainIpcRuntimeServiceDepsParams,
): void {
@@ -26,3 +37,17 @@ export function registerAnkiJimakuIpcRuntimeServices(
);
}
export function registerIpcRuntimeServices(
params: RegisterIpcRuntimeServicesParams,
): void {
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
showMpvOsd: params.runtimeOptions.showMpvOsd,
});
registerMainIpcRuntimeServices({
...params.mainDeps,
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
});
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
}

140
src/main/overlay-runtime.ts Normal file
View File

@@ -0,0 +1,140 @@
import type { BrowserWindow } from "electron";
type OverlayHostedModal = "runtime-options" | "subsync" | "jimaku";
type OverlayHostLayer = "visible" | "invisible";
export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
}
export interface OverlayModalRuntime {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
) => boolean;
openRuntimeOptionsPalette: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
}
export function createOverlayModalRuntimeService(
deps: OverlayWindowResolver,
): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>();
const getTargetOverlayWindow = (): {
window: BrowserWindow;
layer: OverlayHostLayer;
} | null => {
const visibleMainWindow = deps.getMainWindow();
const invisibleWindow = deps.getInvisibleWindow();
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
return { window: visibleMainWindow, layer: "visible" };
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
return { window: invisibleWindow, layer: "invisible" };
}
return null;
};
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
if (layer === "invisible" && typeof window.showInactive === "function") {
window.showInactive();
} else {
window.show();
}
if (!window.isFocused()) {
window.focus();
}
};
const sendToActiveOverlayWindow = (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
): boolean => {
const target = getTargetOverlayWindow();
if (!target) return false;
const { window: targetWindow, layer } = target;
const wasVisible = targetWindow.isVisible();
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
const sendNow = (): void => {
if (payload === undefined) {
targetWindow.webContents.send(channel);
} else {
targetWindow.webContents.send(channel, payload);
}
};
if (!wasVisible) {
showOverlayWindowForModal(targetWindow, layer);
}
if (!wasVisible && restoreOnModalClose) {
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
overlayModalAutoShownLayer.set(restoreOnModalClose, layer);
}
if (targetWindow.webContents.isLoading()) {
targetWindow.webContents.once("did-finish-load", () => {
if (
targetWindow &&
!targetWindow.isDestroyed() &&
!targetWindow.webContents.isLoading()
) {
sendNow();
}
});
return true;
}
sendNow();
return true;
};
const openRuntimeOptionsPalette = (): void => {
sendToActiveOverlayWindow("runtime-options:open", undefined, {
restoreOnModalClose: "runtime-options",
});
};
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal);
const layer = overlayModalAutoShownLayer.get(modal);
overlayModalAutoShownLayer.delete(modal);
if (!layer) return;
const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some(
(pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer,
);
if (shouldKeepLayerVisible) return;
if (layer === "visible") {
const mainWindow = deps.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide();
}
return;
}
const invisibleWindow = deps.getInvisibleWindow();
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
invisibleWindow.hide();
}
};
return {
sendToActiveOverlayWindow,
openRuntimeOptionsPalette,
handleOverlayModalClosed,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
};
}
export type { OverlayHostedModal };

128
src/main/state.ts Normal file
View File

@@ -0,0 +1,128 @@
import type { BrowserWindow, Extension } from "electron";
import type {
Keybinding,
MpvSubtitleRenderMetrics,
SecondarySubMode,
SubtitlePosition,
KikuFieldGroupingChoice,
} from "../types";
import type { CliArgs } from "../cli/args";
import type { SubtitleTimingTracker } from "../subtitle-timing-tracker";
import type { AnkiIntegration } from "../anki-integration";
import type { MpvIpcClient } from "../core/services";
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from "../core/services";
import type { RuntimeOptionsManager } from "../runtime-options";
import type { MecabTokenizer } from "../mecab-tokenizer";
import type { BaseWindowTracker } from "../window-trackers";
export interface AppState {
yomitanExt: Extension | null;
yomitanSettingsWindow: BrowserWindow | null;
yomitanParserWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
currentSubText: string;
currentSubAssText: string;
windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
currentMediaTitle: string | null;
pendingSubtitlePosition: SubtitlePosition | null;
mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[];
subtitleTimingTracker: SubtitleTimingTracker | null;
ankiIntegration: AnkiIntegration | null;
secondarySubMode: SecondarySubMode;
lastSecondarySubToggleAtMs: number;
previousSecondarySubVisibility: boolean | null;
mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics;
shortcutsRegistered: boolean;
overlayRuntimeInitialized: boolean;
fieldGroupingResolver: ((choice: KikuFieldGroupingChoice) => void) | null;
fieldGroupingResolverSequence: number;
runtimeOptionsManager: RuntimeOptionsManager | null;
trackerNotReadyWarningShown: boolean;
overlayDebugVisualizationEnabled: boolean;
subsyncInProgress: boolean;
initialArgs: CliArgs | null;
mpvSocketPath: string;
texthookerPort: number;
backendOverride: string | null;
autoStartOverlay: boolean;
texthookerOnlyMode: boolean;
}
export interface AppStateInitialValues {
mpvSocketPath: string;
texthookerPort: number;
backendOverride?: string | null;
autoStartOverlay?: boolean;
texthookerOnlyMode?: boolean;
}
export interface StartupState {
initialArgs: Exclude<AppState["initialArgs"], null>;
mpvSocketPath: AppState["mpvSocketPath"];
texthookerPort: AppState["texthookerPort"];
backendOverride: AppState["backendOverride"];
autoStartOverlay: AppState["autoStartOverlay"];
texthookerOnlyMode: AppState["texthookerOnlyMode"];
}
export function createAppState(values: AppStateInitialValues): AppState {
return {
yomitanExt: null,
yomitanSettingsWindow: null,
yomitanParserWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
reconnectTimer: null,
currentSubText: "",
currentSubAssText: "",
windowTracker: null,
subtitlePosition: null,
currentMediaPath: null,
currentMediaTitle: null,
pendingSubtitlePosition: null,
mecabTokenizer: null,
keybindings: [],
subtitleTimingTracker: null,
ankiIntegration: null,
secondarySubMode: "hover",
lastSecondarySubToggleAtMs: 0,
previousSecondarySubVisibility: null,
mpvSubtitleRenderMetrics: {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
},
runtimeOptionsManager: null,
trackerNotReadyWarningShown: false,
overlayDebugVisualizationEnabled: false,
shortcutsRegistered: false,
overlayRuntimeInitialized: false,
fieldGroupingResolver: null,
fieldGroupingResolverSequence: 0,
subsyncInProgress: false,
initialArgs: null,
mpvSocketPath: values.mpvSocketPath,
texthookerPort: values.texthookerPort,
backendOverride: values.backendOverride ?? null,
autoStartOverlay: values.autoStartOverlay ?? false,
texthookerOnlyMode: values.texthookerOnlyMode ?? false,
};
}
export function applyStartupState(
appState: AppState,
startupState: StartupState,
): void {
appState.initialArgs = startupState.initialArgs;
appState.mpvSocketPath = startupState.mpvSocketPath;
appState.texthookerPort = startupState.texthookerPort;
appState.backendOverride = startupState.backendOverride;
appState.autoStartOverlay = startupState.autoStartOverlay;
appState.texthookerOnlyMode = startupState.texthookerOnlyMode;
}

View File

@@ -0,0 +1,43 @@
import { SubsyncResolvedConfig } from "../subsync/utils";
import type { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from "../types";
import type { SubsyncRuntimeDeps } from "../core/services/subsync-runner-service";
import { createSubsyncRuntimeDeps } from "./dependencies";
import { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "../core/services";
export interface SubsyncRuntimeServiceInput {
getMpvClient: SubsyncRuntimeDeps["getMpvClient"];
getResolvedSubsyncConfig: () => SubsyncResolvedConfig;
isSubsyncInProgress: SubsyncRuntimeDeps["isSubsyncInProgress"];
setSubsyncInProgress: SubsyncRuntimeDeps["setSubsyncInProgress"];
showMpvOsd: SubsyncRuntimeDeps["showMpvOsd"];
openManualPicker: (payload: SubsyncManualPayload) => void;
}
export function createSubsyncRuntimeServiceDeps(
params: SubsyncRuntimeServiceInput,
): SubsyncRuntimeDeps {
return createSubsyncRuntimeDeps({
getMpvClient: params.getMpvClient,
getResolvedSubsyncConfig: params.getResolvedSubsyncConfig,
isSubsyncInProgress: params.isSubsyncInProgress,
setSubsyncInProgress: params.setSubsyncInProgress,
showMpvOsd: params.showMpvOsd,
openManualPicker: params.openManualPicker,
});
}
export function triggerSubsyncFromConfigRuntime(
params: SubsyncRuntimeServiceInput,
): Promise<void> {
return triggerSubsyncFromConfigRuntimeService(createSubsyncRuntimeServiceDeps(params));
}
export async function runSubsyncManualFromIpcRuntime(
request: SubsyncManualRunRequest,
params: SubsyncRuntimeServiceInput,
): Promise<SubsyncResult> {
return runSubsyncManualFromIpcRuntimeService(
request,
createSubsyncRuntimeServiceDeps(params),
);
}