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

View File

@@ -1,4 +1,4 @@
.PHONY: help deps build install build-linux build-macos build-macos-unsigned install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-pnpm generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop
.PHONY: help deps build install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-pnpm generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop
APP_NAME := subminer
THEME_FILE := subminer.rasi
@@ -48,6 +48,7 @@ help:
" build-linux Build Linux AppImage" \
" build-macos Build macOS DMG/ZIP (signed if configured)" \
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
" dev-start Build and launch local Electron app" \
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
" dev-toggle Toggle overlay in a running local Electron app" \
@@ -130,6 +131,13 @@ build-macos-unsigned: deps
@pnpm -C vendor/texthooker-ui build
@pnpm run build:mac:unsigned
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@rm -f release/SubMiner-*.AppImage
@rm -f release/linux-unpacked/SubMiner
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
@rm -rf dist release
generate-config: ensure-pnpm
@pnpm run build
@pnpm exec electron . --generate-config

View File

@@ -109,6 +109,12 @@ Adopted sequence (from TASK-27 parent):
- Risk: lifecycle/cleanup regressions from moving startup hooks and shutdown behavior
- Mitigation: preserve service construction order and keep existing event registration boundaries
Migration note:
- `src/main.ts` now delegates composition edges to `src/main/startup.ts`, `src/main/app-lifecycle.ts`, `src/main/ipc-runtime.ts`, `src/main/cli-runtime.ts`, and `src/main/subsync-runtime.ts`.
- Overlay/modal interaction has been moved into `src/main/overlay-runtime.ts` (window selection, modal restore set tracking, runtime-options palette/modal close handling) so `main.ts` now uses a dedicated runtime service for modal routing instead of inline window bookkeeping.
- Stateful runtime session data has moved to `src/main/state.ts` via `createAppState()` so `main.ts` no longer owns the `AppState` shape inline, only importing and mutating the shared state instance.
- Behavioral contract remains stable: startup flow, CLI dispatch, IPC handlers, and subsync orchestration keep existing external behavior; only dependency wiring moved out of `main.ts`.
### TASK-27.4 (mpv-service)
- Risk: request/deps interface changes could break control and subsync interactions
- Mitigation: preserve public `MpvClient` methods, request semantics, and reconnect events while splitting internal modules

View File

@@ -0,0 +1,167 @@
import {
MPV_REQUEST_ID_AID,
MPV_REQUEST_ID_OSD_DIMENSIONS,
MPV_REQUEST_ID_OSD_HEIGHT,
MPV_REQUEST_ID_PATH,
MPV_REQUEST_ID_SECONDARY_SUBTEXT,
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
MPV_REQUEST_ID_SUB_BOLD,
MPV_REQUEST_ID_SUB_BORDER_SIZE,
MPV_REQUEST_ID_SUB_FONT,
MPV_REQUEST_ID_SUB_FONT_SIZE,
MPV_REQUEST_ID_SUB_ITALIC,
MPV_REQUEST_ID_SUB_MARGIN_X,
MPV_REQUEST_ID_SUB_MARGIN_Y,
MPV_REQUEST_ID_SUB_POS,
MPV_REQUEST_ID_SUB_SCALE,
MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
MPV_REQUEST_ID_SUB_SPACING,
MPV_REQUEST_ID_SUBTEXT,
MPV_REQUEST_ID_SUBTEXT_ASS,
MPV_REQUEST_ID_SUB_USE_MARGINS,
} from "./mpv-protocol";
type MpvProtocolCommand = {
command: unknown[];
request_id?: number;
};
export interface MpvSendCommand {
(command: MpvProtocolCommand): boolean;
}
const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
"sub-text",
"path",
"sub-start",
"sub-end",
"time-pos",
"secondary-sub-text",
"aid",
"sub-pos",
"sub-font-size",
"sub-scale",
"sub-margin-y",
"sub-margin-x",
"sub-font",
"sub-spacing",
"sub-bold",
"sub-italic",
"sub-scale-by-window",
"osd-height",
"osd-dimensions",
"sub-text-ass",
"sub-border-size",
"sub-shadow-offset",
"sub-ass-override",
"sub-use-margins",
"media-title",
];
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
{
command: ["get_property", "sub-text"],
request_id: MPV_REQUEST_ID_SUBTEXT,
},
{
command: ["get_property", "sub-text-ass"],
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
},
{
command: ["get_property", "path"],
request_id: MPV_REQUEST_ID_PATH,
},
{
command: ["get_property", "media-title"],
},
{
command: ["get_property", "secondary-sub-text"],
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,
},
{
command: ["get_property", "secondary-sub-visibility"],
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
},
{
command: ["get_property", "aid"],
request_id: MPV_REQUEST_ID_AID,
},
{
command: ["get_property", "sub-pos"],
request_id: MPV_REQUEST_ID_SUB_POS,
},
{
command: ["get_property", "sub-font-size"],
request_id: MPV_REQUEST_ID_SUB_FONT_SIZE,
},
{
command: ["get_property", "sub-scale"],
request_id: MPV_REQUEST_ID_SUB_SCALE,
},
{
command: ["get_property", "sub-margin-y"],
request_id: MPV_REQUEST_ID_SUB_MARGIN_Y,
},
{
command: ["get_property", "sub-margin-x"],
request_id: MPV_REQUEST_ID_SUB_MARGIN_X,
},
{
command: ["get_property", "sub-font"],
request_id: MPV_REQUEST_ID_SUB_FONT,
},
{
command: ["get_property", "sub-spacing"],
request_id: MPV_REQUEST_ID_SUB_SPACING,
},
{
command: ["get_property", "sub-bold"],
request_id: MPV_REQUEST_ID_SUB_BOLD,
},
{
command: ["get_property", "sub-italic"],
request_id: MPV_REQUEST_ID_SUB_ITALIC,
},
{
command: ["get_property", "sub-scale-by-window"],
request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
},
{
command: ["get_property", "osd-height"],
request_id: MPV_REQUEST_ID_OSD_HEIGHT,
},
{
command: ["get_property", "osd-dimensions"],
request_id: MPV_REQUEST_ID_OSD_DIMENSIONS,
},
{
command: ["get_property", "sub-border-size"],
request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE,
},
{
command: ["get_property", "sub-shadow-offset"],
request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
},
{
command: ["get_property", "sub-ass-override"],
request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
},
{
command: ["get_property", "sub-use-margins"],
request_id: MPV_REQUEST_ID_SUB_USE_MARGINS,
},
];
export function subscribeToMpvProperties(send: MpvSendCommand): void {
MPV_SUBTITLE_PROPERTY_OBSERVATIONS.forEach((property, index) => {
send({ command: ["observe_property", index + 1, property] });
});
}
export function requestMpvInitialState(send: MpvSendCommand): void {
MPV_INITIAL_PROPERTY_REQUESTS.forEach((payload) => {
send(payload);
});
}

View File

@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { MpvSubtitleRenderMetrics } from "../../types";
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
parseVisibilityProperty,
asBoolean,
} from "./mpv-protocol";
function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
deps: MpvProtocolHandleMessageDeps;
state: {
subText: string;
secondarySubText: string;
events: Array<unknown>;
commands: unknown[];
mediaPath: string;
restored: number;
};
} {
const state = {
subText: "",
secondarySubText: "",
events: [] as Array<unknown>,
commands: [] as unknown[],
mediaPath: "",
restored: 0,
};
const metrics: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: "",
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: "yes",
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
return {
state,
deps: {
getResolvedConfig: () => ({ secondarySub: { secondarySubLanguages: ["ja"] } }),
getSubtitleMetrics: () => metrics,
isVisibleOverlayVisible: () => false,
emitSubtitleChange: (payload) => state.events.push(payload),
emitSubtitleAssChange: (payload) => state.events.push(payload),
emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => {
state.subText = text;
},
setCurrentSubStart: () => {},
getCurrentSubStart: () => 0,
setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0,
emitMediaPathChange: (payload) => {
state.mediaPath = payload.path;
},
emitMediaTitleChange: (payload) => state.events.push(payload),
emitSubtitleMetricsChange: (payload) => state.events.push(payload),
setCurrentSecondarySubText: (text) => {
state.secondarySubText = text;
},
resolvePendingRequest: () => false,
setSecondarySubVisibility: () => {},
syncCurrentAudioStreamIndex: () => {},
setCurrentAudioTrackId: () => {},
setCurrentTimePos: () => {},
getCurrentTimePos: () => 0,
getPendingPauseAtSubEnd: () => false,
setPendingPauseAtSubEnd: () => {},
getPauseAtTime: () => null,
setPauseAtTime: () => {},
autoLoadSecondarySubTrack: () => {},
setCurrentVideoPath: () => {},
emitSecondarySubtitleVisibility: (payload) => state.events.push(payload),
setCurrentAudioStreamIndex: () => {},
sendCommand: (payload) => {
state.commands.push(payload);
return true;
},
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
...overrides,
},
};
}
test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: "property-change", name: "sub-text", data: "字幕" },
deps,
);
assert.equal(state.subText, "字幕");
assert.deepEqual(state.events, [{ text: "字幕", isOverlayVisible: false }]);
});
test("dispatchMpvProtocolMessage sets secondary subtitle track based on track list response", async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{ type: "audio", id: 1, lang: "eng" },
{ type: "sub", id: 2, lang: "ja" },
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ["set_property", "secondary-sid", 2] }]);
});
test("dispatchMpvProtocolMessage restores secondary visibility on shutdown", async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage({ event: "shutdown" }, deps);
assert.equal(state.restored, 1);
});
test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer", () => {
const parsed = splitMpvMessagesFromBuffer(
"{\"event\":\"shutdown\"}\\n{\"event\":\"property-change\",\"name\":\"media-title\",\"data\":\"x\"}\\n{\"partial\"",
);
assert.equal(parsed.messages.length, 2);
assert.equal(parsed.nextBuffer, "{\"partial\"");
assert.equal(parsed.messages[0].event, "shutdown");
assert.equal(parsed.messages[1].name, "property-change");
});
test("splitMpvMessagesFromBuffer reports invalid JSON lines", () => {
const errors: Array<{ line: string; error?: string }> = [];
splitMpvMessagesFromBuffer("{\"event\":\"x\"}\\n{invalid}\\n", undefined, (line, error) => {
errors.push({ line, error: String(error) });
});
assert.equal(errors.length, 1);
assert.equal(errors[0].line, "{invalid}");
});
test("visibility and boolean parsers handle text values", () => {
assert.equal(parseVisibilityProperty("true"), true);
assert.equal(parseVisibilityProperty("0"), false);
assert.equal(parseVisibilityProperty("unknown"), null);
assert.equal(asBoolean("yes", false), true);
assert.equal(asBoolean("0", true), false);
});

View File

@@ -7,34 +7,14 @@ import {
} from "../../types";
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_AID,
MPV_REQUEST_ID_OSD_DIMENSIONS,
MPV_REQUEST_ID_OSD_HEIGHT,
MPV_REQUEST_ID_PATH,
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MPV_REQUEST_ID_SECONDARY_SUBTEXT,
MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
MPV_REQUEST_ID_SUB_BOLD,
MPV_REQUEST_ID_SUB_BORDER_SIZE,
MPV_REQUEST_ID_SUB_FONT,
MPV_REQUEST_ID_SUB_FONT_SIZE,
MPV_REQUEST_ID_SUB_ITALIC,
MPV_REQUEST_ID_SUB_MARGIN_X,
MPV_REQUEST_ID_SUB_MARGIN_Y,
MPV_REQUEST_ID_SUB_POS,
MPV_REQUEST_ID_SUB_SCALE,
MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
MPV_REQUEST_ID_SUB_SPACING,
MPV_REQUEST_ID_SUBTEXT,
MPV_REQUEST_ID_SUBTEXT_ASS,
MPV_REQUEST_ID_SUB_USE_MARGINS,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
MpvMessage,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
} from "./mpv-protocol";
import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties";
import { getMpvReconnectDelay } from "./mpv-transport";
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
@@ -170,8 +150,8 @@ export class MpvIpcClient implements MpvClient {
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
this.subscribeToProperties();
this.getInitialState();
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
const shouldAutoStart =
this.deps.autoStartOverlay ||
@@ -217,28 +197,7 @@ export class MpvIpcClient implements MpvClient {
clearTimeout(reconnectTimer);
}
const attempt = this.reconnectAttempt++;
let delay: number;
if (this.hasConnectedOnce) {
if (attempt < 2) {
delay = 1000;
} else if (attempt < 4) {
delay = 2000;
} else if (attempt < 7) {
delay = 5000;
} else {
delay = 10000;
}
} else {
if (attempt < 2) {
delay = 200;
} else if (attempt < 4) {
delay = 500;
} else if (attempt < 6) {
delay = 1000;
} else {
delay = 2000;
}
}
const delay = getMpvReconnectDelay(attempt, this.hasConnectedOnce);
this.deps.setReconnectTimer(
setTimeout(() => {
console.log(
@@ -457,128 +416,6 @@ export class MpvIpcClient implements MpvClient {
return true;
}
private subscribeToProperties(): void {
this.send({ command: ["observe_property", 1, "sub-text"] });
this.send({ command: ["observe_property", 2, "path"] });
this.send({ command: ["observe_property", 3, "sub-start"] });
this.send({ command: ["observe_property", 4, "sub-end"] });
this.send({ command: ["observe_property", 5, "time-pos"] });
this.send({ command: ["observe_property", 6, "secondary-sub-text"] });
this.send({ command: ["observe_property", 7, "aid"] });
this.send({ command: ["observe_property", 8, "sub-pos"] });
this.send({ command: ["observe_property", 9, "sub-font-size"] });
this.send({ command: ["observe_property", 10, "sub-scale"] });
this.send({ command: ["observe_property", 11, "sub-margin-y"] });
this.send({ command: ["observe_property", 12, "sub-margin-x"] });
this.send({ command: ["observe_property", 13, "sub-font"] });
this.send({ command: ["observe_property", 14, "sub-spacing"] });
this.send({ command: ["observe_property", 15, "sub-bold"] });
this.send({ command: ["observe_property", 16, "sub-italic"] });
this.send({ command: ["observe_property", 17, "sub-scale-by-window"] });
this.send({ command: ["observe_property", 18, "osd-height"] });
this.send({ command: ["observe_property", 19, "osd-dimensions"] });
this.send({ command: ["observe_property", 20, "sub-text-ass"] });
this.send({ command: ["observe_property", 21, "sub-border-size"] });
this.send({ command: ["observe_property", 22, "sub-shadow-offset"] });
this.send({ command: ["observe_property", 23, "sub-ass-override"] });
this.send({ command: ["observe_property", 24, "sub-use-margins"] });
this.send({ command: ["observe_property", 25, "media-title"] });
}
private getInitialState(): void {
this.send({
command: ["get_property", "sub-text"],
request_id: MPV_REQUEST_ID_SUBTEXT,
});
this.send({
command: ["get_property", "sub-text-ass"],
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
});
this.send({
command: ["get_property", "path"],
request_id: MPV_REQUEST_ID_PATH,
});
this.send({
command: ["get_property", "media-title"],
});
this.send({
command: ["get_property", "secondary-sub-text"],
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,
});
this.send({
command: ["get_property", "secondary-sub-visibility"],
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
});
this.send({
command: ["get_property", "aid"],
request_id: MPV_REQUEST_ID_AID,
});
this.send({
command: ["get_property", "sub-pos"],
request_id: MPV_REQUEST_ID_SUB_POS,
});
this.send({
command: ["get_property", "sub-font-size"],
request_id: MPV_REQUEST_ID_SUB_FONT_SIZE,
});
this.send({
command: ["get_property", "sub-scale"],
request_id: MPV_REQUEST_ID_SUB_SCALE,
});
this.send({
command: ["get_property", "sub-margin-y"],
request_id: MPV_REQUEST_ID_SUB_MARGIN_Y,
});
this.send({
command: ["get_property", "sub-margin-x"],
request_id: MPV_REQUEST_ID_SUB_MARGIN_X,
});
this.send({
command: ["get_property", "sub-font"],
request_id: MPV_REQUEST_ID_SUB_FONT,
});
this.send({
command: ["get_property", "sub-spacing"],
request_id: MPV_REQUEST_ID_SUB_SPACING,
});
this.send({
command: ["get_property", "sub-bold"],
request_id: MPV_REQUEST_ID_SUB_BOLD,
});
this.send({
command: ["get_property", "sub-italic"],
request_id: MPV_REQUEST_ID_SUB_ITALIC,
});
this.send({
command: ["get_property", "sub-scale-by-window"],
request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
});
this.send({
command: ["get_property", "osd-height"],
request_id: MPV_REQUEST_ID_OSD_HEIGHT,
});
this.send({
command: ["get_property", "osd-dimensions"],
request_id: MPV_REQUEST_ID_OSD_DIMENSIONS,
});
this.send({
command: ["get_property", "sub-border-size"],
request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE,
});
this.send({
command: ["get_property", "sub-shadow-offset"],
request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
});
this.send({
command: ["get_property", "sub-ass-override"],
request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
});
this.send({
command: ["get_property", "sub-use-margins"],
request_id: MPV_REQUEST_ID_SUB_USE_MARGINS,
});
}
setSubVisibility(visible: boolean): void {
this.send({
command: ["set_property", "sub-visibility", visible ? "yes" : "no"],

View File

@@ -0,0 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getMpvReconnectDelay } from "./mpv-transport";
test("getMpvReconnectDelay follows existing reconnect ramp", () => {
assert.equal(getMpvReconnectDelay(0, true), 1000);
assert.equal(getMpvReconnectDelay(1, true), 1000);
assert.equal(getMpvReconnectDelay(2, true), 2000);
assert.equal(getMpvReconnectDelay(4, true), 5000);
assert.equal(getMpvReconnectDelay(7, true), 10000);
assert.equal(getMpvReconnectDelay(0, false), 200);
assert.equal(getMpvReconnectDelay(2, false), 500);
assert.equal(getMpvReconnectDelay(4, false), 1000);
assert.equal(getMpvReconnectDelay(6, false), 2000);
});

View File

@@ -0,0 +1,28 @@
export function getMpvReconnectDelay(
attempt: number,
hasConnectedOnce: boolean,
): number {
if (hasConnectedOnce) {
if (attempt < 2) {
return 1000;
}
if (attempt < 4) {
return 2000;
}
if (attempt < 7) {
return 5000;
}
return 10000;
}
if (attempt < 2) {
return 200;
}
if (attempt < 4) {
return 500;
}
if (attempt < 6) {
return 1000;
}
return 2000;
}

View File

@@ -42,21 +42,17 @@ import * as path from "path";
import * as os from "os";
import * as fs from "fs";
import { MecabTokenizer } from "./mecab-tokenizer";
import { BaseWindowTracker } from "./window-trackers";
import type {
JimakuApiResponse,
JimakuLanguagePreference,
SubtitleData,
SubtitlePosition,
Keybinding,
WindowGeometry,
SecondarySubMode,
SubsyncManualPayload,
SubsyncManualRunRequest,
SubsyncResult,
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
KikuMergePreviewResponse,
RuntimeOptionState,
MpvSubtitleRenderMetrics,
ResolvedConfig,
@@ -94,7 +90,6 @@ import {
broadcastRuntimeOptionsChangedRuntimeService,
copyCurrentSubtitleService,
createAppLifecycleDepsRuntimeService,
createCliCommandDepsRuntimeService,
createOverlayManagerService,
createFieldGroupingOverlayRuntimeService,
createNumericShortcutRuntimeService,
@@ -108,7 +103,6 @@ import {
getInitialInvisibleOverlayVisibilityService,
getJimakuLanguagePreferenceService,
getJimakuMaxEntryResultsService,
handleCliCommandService,
handleMineSentenceDigitService,
handleMultiCopyDigitService,
hasMpvWebsocketPlugin,
@@ -128,7 +122,6 @@ import {
replayCurrentSubtitleRuntimeService,
resolveJimakuApiKeyService,
runStartupBootstrapRuntimeService,
runSubsyncManualFromIpcRuntimeService,
saveSubtitlePositionService,
sendMpvCommandRuntimeService,
setInvisibleOverlayVisibleService,
@@ -144,7 +137,6 @@ import {
syncOverlayShortcutsRuntimeService,
tokenizeSubtitleService,
triggerFieldGroupingService,
triggerSubsyncFromConfigRuntimeService,
unregisterOverlayShortcutsRuntimeService,
updateCurrentMediaPathService,
updateInvisibleOverlayVisibilityService,
@@ -156,22 +148,28 @@ import {
runAppReadyRuntimeService,
} from "./core/services/startup-service";
import type { AppReadyRuntimeDeps } from "./core/services/startup-service";
import type { SubsyncRuntimeDeps } from "./core/services/subsync-runner-service";
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
import {
createRuntimeOptionsIpcDeps,
createCliCommandRuntimeServiceDeps,
createSubsyncRuntimeDeps,
} from "./main/dependencies";
import {
createAppLifecycleRuntimeDeps as createAppLifecycleRuntimeDepsBuilder,
createAppReadyRuntimeDeps as createAppReadyRuntimeDepsBuilder,
} from "./main/app-lifecycle";
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
import {
registerAnkiJimakuIpcRuntimeServices,
registerMainIpcRuntimeServices,
registerIpcRuntimeServices,
} from "./main/ipc-runtime";
import { handleCliCommandRuntimeService } from "./main/cli-runtime";
import {
runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime,
} from "./main/subsync-runtime";
import {
createOverlayModalRuntimeService,
type OverlayHostedModal,
} from "./main/overlay-runtime";
import {
applyStartupState,
createAppState,
} from "./main/state";
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
import {
ConfigService,
@@ -284,90 +282,14 @@ const overlayContentMeasurementStore = createOverlayContentMeasurementStoreServi
console.warn(message);
},
});
type OverlayHostedModal = "runtime-options" | "subsync" | "jimaku";
type OverlayHostLayer = "visible" | "invisible";
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>();
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;
}
const appState: AppState = {
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,
},
shortcutsRegistered: false,
overlayRuntimeInitialized: false,
fieldGroupingResolver: null,
fieldGroupingResolverSequence: 0,
runtimeOptionsManager: null,
trackerNotReadyWarningShown: false,
overlayDebugVisualizationEnabled: false,
subsyncInProgress: false,
initialArgs: null,
const overlayModalRuntime = createOverlayModalRuntimeService({
getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
});
const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
backendOverride: null,
autoStartOverlay: false,
texthookerOnlyMode: false,
};
});
function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null {
return appState.fieldGroupingResolver;
@@ -396,10 +318,14 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntimeService<Ove
setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
getResolver: () => getFieldGroupingResolver(),
setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
sendToVisibleOverlay: (channel, payload) => {
sendToActiveOverlayWindow(channel, payload);
return true;
getRestoreVisibleOverlayOnModalClose: () =>
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
sendToVisibleOverlay: (channel, payload, runtimeOptions) => {
return overlayModalRuntime.sendToActiveOverlayWindow(
channel,
payload,
runtimeOptions,
);
},
});
const createFieldGroupingCallback =
@@ -429,77 +355,16 @@ function broadcastRuntimeOptionsChanged(): void {
);
}
function getTargetOverlayWindow(): {
window: BrowserWindow;
layer: OverlayHostLayer;
} | null {
const visibleMainWindow = overlayManager.getMainWindow();
const invisibleWindow = overlayManager.getInvisibleWindow();
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
return { window: visibleMainWindow, layer: "visible" };
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
return { window: invisibleWindow, layer: "invisible" };
}
return null;
}
function showOverlayWindowForModal(window: BrowserWindow, layer: OverlayHostLayer): void {
if (layer === "invisible" && typeof window.showInactive === "function") {
window.showInactive();
} else {
window.show();
}
if (!window.isFocused()) {
window.focus();
}
}
function sendToActiveOverlayWindow(
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
): void {
const target = getTargetOverlayWindow();
if (!target) return;
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;
}
sendNow();
): boolean {
return overlayModalRuntime.sendToActiveOverlayWindow(
channel,
payload,
runtimeOptions,
);
}
function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
@@ -514,9 +379,7 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
}
function openRuntimeOptionsPalette(): void {
sendToActiveOverlayWindow("runtime-options:open", undefined, {
restoreOnModalClose: "runtime-options",
});
overlayModalRuntime.openRuntimeOptionsPalette();
}
function getResolvedConfig() { return configService.getConfig(); }
@@ -673,12 +536,7 @@ const startupState = runStartupBootstrapRuntimeService(
}),
);
appState.initialArgs = startupState.initialArgs;
appState.mpvSocketPath = startupState.mpvSocketPath;
appState.texthookerPort = startupState.texthookerPort;
appState.backendOverride = startupState.backendOverride;
appState.autoStartOverlay = startupState.autoStartOverlay;
appState.texthookerOnlyMode = startupState.texthookerOnlyMode;
applyStartupState(appState, startupState);
function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
return createAppLifecycleRuntimeDepsBuilder({
@@ -799,8 +657,7 @@ function handleCliCommand(
args: CliArgs,
source: CliCommandSource = "initial",
): void {
const deps = createCliCommandDepsRuntimeService(
createCliCommandRuntimeServiceDeps({
handleCliCommandRuntimeService(args, source, {
mpv: {
getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
@@ -815,8 +672,7 @@ function handleCliCommand(
setPort: (port: number) => {
appState.texthookerPort = port;
},
shouldOpenBrowser: () =>
getResolvedConfig().texthooker?.openBrowser !== false,
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => {
void shell.openExternal(url).catch((error) => {
console.error(`Failed to open browser for texthooker URL: ${url}`, error);
@@ -863,9 +719,7 @@ function handleCliCommand(
error: (message: string, err: unknown) => {
console.error(message, err);
},
}),
);
handleCliCommandService(args, source, deps);
});
}
function handleInitialArgs(): void {
@@ -1218,8 +1072,8 @@ const numericShortcutRuntime = createNumericShortcutRuntimeService({
const multiCopySession = numericShortcutRuntime.createSession();
const mineSentenceSession = numericShortcutRuntime.createSession();
function getSubsyncRuntimeDeps(): SubsyncRuntimeDeps {
return createSubsyncRuntimeDeps({
function getSubsyncRuntimeServiceParams() {
return {
getMpvClient: () => appState.mpvClient,
getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync),
isSubsyncInProgress: () => appState.subsyncInProgress,
@@ -1232,11 +1086,11 @@ function getSubsyncRuntimeDeps(): SubsyncRuntimeDeps {
restoreOnModalClose: "subsync",
});
},
});
};
}
async function triggerSubsyncFromConfig(): Promise<void> {
await triggerSubsyncFromConfigRuntimeService(getSubsyncRuntimeDeps());
await triggerSubsyncFromConfigRuntime(getSubsyncRuntimeServiceParams());
}
function cancelPendingMultiCopy(): void {
@@ -1474,27 +1328,7 @@ function toggleInvisibleOverlay(): void {
function setOverlayVisible(visible: boolean): void { setVisibleOverlayVisible(visible); }
function toggleOverlay(): void { toggleVisibleOverlay(); }
function 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 = overlayManager.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide();
}
return;
}
const invisibleWindow = overlayManager.getInvisibleWindow();
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
invisibleWindow.hide();
}
overlayModalRuntime.handleOverlayModalClosed(modal);
}
function handleMpvCommandFromIpc(command: (string | number)[]): void {
@@ -1523,20 +1357,21 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
async function runSubsyncManualFromIpc(
request: SubsyncManualRunRequest,
): Promise<SubsyncResult> {
return runSubsyncManualFromIpcRuntimeService(request, getSubsyncRuntimeDeps());
return runSubsyncManualFromIpcRuntime(request, getSubsyncRuntimeServiceParams());
}
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
function buildIpcRuntimeServicesParams() {
return {
runtimeOptions: {
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
showMpvOsd,
});
registerMainIpcRuntimeServices({
showMpvOsd: (text: string) => showMpvOsd(text),
},
mainDeps: {
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
onOverlayModalClosed: (modal) => {
onOverlayModalClosed: (modal: string) => {
handleOverlayModalClosed(modal as OverlayHostedModal);
},
openYomitanSettings: () => openYomitanSettings(),
@@ -1559,14 +1394,11 @@ registerMainIpcRuntimeServices({
runSubsyncManualFromIpc(request as SubsyncManualRunRequest),
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
getRuntimeOptions: () => getRuntimeOptionsState(),
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload);
},
});
registerAnkiJimakuIpcRuntimeServices({
},
ankiJimakuDeps: {
patchAnkiConnectEnabled: (enabled: boolean) => {
configService.patchRawConfig({ ankiConnect: { enabled } });
},
@@ -1602,4 +1434,8 @@ registerAnkiJimakuIpcRuntimeServices({
destPath: string,
headers: Record<string, string>,
) => downloadToFile(url, destPath, headers),
});
},
};
}
registerIpcRuntimeServices(buildIpcRuntimeServicesParams());

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