From 5a610d9d02d566dcb2c14aa228b32e3b2e540586 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 14 Feb 2026 15:06:20 -0800 Subject: [PATCH] refactor state and overlay runtime helpers --- Makefile | 10 +- docs/structure-roadmap.md | 6 + src/core/services/mpv-properties.ts | 167 ++++++++ src/core/services/mpv-protocol.test.ts | 168 ++++++++ src/core/services/mpv-service.ts | 173 +------- src/core/services/mpv-transport.test.ts | 16 + src/core/services/mpv-transport.ts | 28 ++ src/main.ts | 524 ++++++++---------------- src/main/cli-runtime.ts | 15 + src/main/ipc-runtime.ts | 25 ++ src/main/overlay-runtime.ts | 140 +++++++ src/main/state.ts | 128 ++++++ src/main/subsync-runtime.ts | 43 ++ vendor/texthooker-ui | 2 +- 14 files changed, 931 insertions(+), 514 deletions(-) create mode 100644 src/core/services/mpv-properties.ts create mode 100644 src/core/services/mpv-protocol.test.ts create mode 100644 src/core/services/mpv-transport.test.ts create mode 100644 src/core/services/mpv-transport.ts create mode 100644 src/main/cli-runtime.ts create mode 100644 src/main/overlay-runtime.ts create mode 100644 src/main/state.ts create mode 100644 src/main/subsync-runtime.ts diff --git a/Makefile b/Makefile index 663ee32..a679f34 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/structure-roadmap.md b/docs/structure-roadmap.md index ba31fd5..e7ba11d 100644 --- a/docs/structure-roadmap.md +++ b/docs/structure-roadmap.md @@ -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 diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts new file mode 100644 index 0000000..798ede9 --- /dev/null +++ b/src/core/services/mpv-properties.ts @@ -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 = [ + { + 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); + }); +} diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts new file mode 100644 index 0000000..b2dddc8 --- /dev/null +++ b/src/core/services/mpv-protocol.test.ts @@ -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 = {}): { + deps: MpvProtocolHandleMessageDeps; + state: { + subText: string; + secondarySubText: string; + events: Array; + commands: unknown[]; + mediaPath: string; + restored: number; + }; +} { + const state = { + subText: "", + secondarySubText: "", + events: [] as Array, + 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); +}); diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts index 2608061..fcde05d 100644 --- a/src/core/services/mpv-service.ts +++ b/src/core/services/mpv-service.ts @@ -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"], diff --git a/src/core/services/mpv-transport.test.ts b/src/core/services/mpv-transport.test.ts new file mode 100644 index 0000000..f14cacb --- /dev/null +++ b/src/core/services/mpv-transport.test.ts @@ -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); +}); diff --git a/src/core/services/mpv-transport.ts b/src/core/services/mpv-transport.ts new file mode 100644 index 0000000..8bf64ac --- /dev/null +++ b/src/core/services/mpv-transport.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 6b9aae0..d03d5d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); -const overlayModalAutoShownLayer = new Map(); - -interface AppState { - yomitanExt: Extension | null; - yomitanSettingsWindow: BrowserWindow | null; - yomitanParserWindow: BrowserWindow | null; - yomitanParserReadyPromise: Promise | null; - yomitanParserInitPromise: Promise | null; - mpvClient: MpvIpcClient | null; - reconnectTimer: ReturnType | 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 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,73 +657,69 @@ function handleCliCommand( args: CliArgs, source: CliCommandSource = "initial", ): void { - const deps = createCliCommandDepsRuntimeService( - createCliCommandRuntimeServiceDeps({ - mpv: { - getSocketPath: () => appState.mpvSocketPath, - setSocketPath: (socketPath: string) => { - appState.mpvSocketPath = socketPath; - }, - getClient: () => appState.mpvClient, - showOsd: (text: string) => showMpvOsd(text), + handleCliCommandRuntimeService(args, source, { + mpv: { + getSocketPath: () => appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + appState.mpvSocketPath = socketPath; }, - texthooker: { - service: texthookerService, - getPort: () => appState.texthookerPort, - setPort: (port: number) => { - appState.texthookerPort = port; - }, - 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); - }); - }, + getClient: () => appState.mpvClient, + showOsd: (text: string) => showMpvOsd(text), + }, + texthooker: { + service: texthookerService, + getPort: () => appState.texthookerPort, + setPort: (port: number) => { + appState.texthookerPort = port; }, - overlay: { - isInitialized: () => appState.overlayRuntimeInitialized, - initialize: () => initializeOverlayRuntime(), - toggleVisible: () => toggleVisibleOverlay(), - toggleInvisible: () => toggleInvisibleOverlay(), - setVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible), + 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); + }); }, - mining: { - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - }, - ui: { - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => cycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - }, - app: { - stop: () => app.quit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), - }, - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, - schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), - log: (message: string) => { - console.log(message); - }, - warn: (message: string) => { - console.warn(message); - }, - error: (message: string, err: unknown) => { - console.error(message, err); - }, - }), - ); - handleCliCommandService(args, source, deps); + }, + overlay: { + isInitialized: () => appState.overlayRuntimeInitialized, + initialize: () => initializeOverlayRuntime(), + toggleVisible: () => toggleVisibleOverlay(), + toggleInvisible: () => toggleInvisibleOverlay(), + setVisible: (visible: boolean) => setVisibleOverlayVisible(visible), + setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible), + }, + mining: { + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + }, + ui: { + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + }, + app: { + stop: () => app.quit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + }, + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), + log: (message: string) => { + console.log(message); + }, + warn: (message: string) => { + console.warn(message); + }, + error: (message: string, err: unknown) => { + console.error(message, err); + }, + }); } 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 { - 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,83 +1357,85 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void { async function runSubsyncManualFromIpc( request: SubsyncManualRunRequest, ): Promise { - return runSubsyncManualFromIpcRuntimeService(request, getSubsyncRuntimeDeps()); + return runSubsyncManualFromIpcRuntime(request, getSubsyncRuntimeServiceParams()); } -const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({ - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - showMpvOsd, -}); +function buildIpcRuntimeServicesParams() { + return { + runtimeOptions: { + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + showMpvOsd: (text: string) => showMpvOsd(text), + }, + mainDeps: { + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), + onOverlayModalClosed: (modal: string) => { + handleOverlayModalClosed(modal as OverlayHostedModal); + }, + openYomitanSettings: () => openYomitanSettings(), + quitApp: () => app.quit(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), + getCurrentSubtitleAss: () => appState.currentSubAssText, + getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, + getSubtitlePosition: () => loadSubtitlePosition(), + getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null, + saveSubtitlePosition: (position: unknown) => + saveSubtitlePosition(position as SubtitlePosition), + getMecabTokenizer: () => appState.mecabTokenizer, + handleMpvCommand: (command: (string | number)[]) => + handleMpvCommandFromIpc(command), + getKeybindings: () => appState.keybindings, + getSecondarySubMode: () => appState.secondarySubMode, + getMpvClient: () => appState.mpvClient, + runSubsyncManual: (request: unknown) => + runSubsyncManualFromIpc(request as SubsyncManualRunRequest), + getAnkiConnectStatus: () => appState.ankiIntegration !== null, + getRuntimeOptions: () => getRuntimeOptionsState(), + reportOverlayContentBounds: (payload: unknown) => { + overlayContentMeasurementStore.report(payload); + }, + }, + ankiJimakuDeps: { + patchAnkiConnectEnabled: (enabled: boolean) => { + configService.patchRawConfig({ ankiConnect: { enabled } }); + }, + getResolvedConfig: () => getResolvedConfig(), + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getMpvClient: () => appState.mpvClient, + getAnkiIntegration: () => appState.ankiIntegration, + setAnkiIntegration: (integration: AnkiIntegration | null) => { + appState.ankiIntegration = integration; + }, + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + getFieldGroupingResolver: () => getFieldGroupingResolver(), + setFieldGroupingResolver: ( + resolver: ((choice: KikuFieldGroupingChoice) => void) | null, + ) => setFieldGroupingResolver(resolver), + parseMediaInfo: (mediaPath: string | null) => + parseMediaInfo(resolveMediaPathForJimaku(mediaPath)), + getCurrentMediaPath: () => appState.currentMediaPath, + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ): Promise> => + jimakuFetchJson(endpoint, query), + getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), + getJimakuLanguagePreference: () => getJimakuLanguagePreference(), + resolveJimakuApiKey: () => resolveJimakuApiKey(), + isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), + downloadToFile: ( + url: string, + destPath: string, + headers: Record, + ) => downloadToFile(url, destPath, headers), + }, + }; +} -registerMainIpcRuntimeServices({ - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), - onOverlayModalClosed: (modal) => { - handleOverlayModalClosed(modal as OverlayHostedModal); - }, - openYomitanSettings: () => openYomitanSettings(), - quitApp: () => app.quit(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), - getCurrentSubtitleAss: () => appState.currentSubAssText, - getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, - getSubtitlePosition: () => loadSubtitlePosition(), - getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null, - saveSubtitlePosition: (position: unknown) => - saveSubtitlePosition(position as SubtitlePosition), - getMecabTokenizer: () => appState.mecabTokenizer, - handleMpvCommand: (command: (string | number)[]) => - handleMpvCommandFromIpc(command), - getKeybindings: () => appState.keybindings, - getSecondarySubMode: () => appState.secondarySubMode, - getMpvClient: () => appState.mpvClient, - runSubsyncManual: (request: unknown) => - runSubsyncManualFromIpc(request as SubsyncManualRunRequest), - getAnkiConnectStatus: () => appState.ankiIntegration !== null, - getRuntimeOptions: () => getRuntimeOptionsState(), - setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, - cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, - reportOverlayContentBounds: (payload: unknown) => { - overlayContentMeasurementStore.report(payload); - }, -}); - -registerAnkiJimakuIpcRuntimeServices({ - patchAnkiConnectEnabled: (enabled: boolean) => { - configService.patchRawConfig({ ankiConnect: { enabled } }); - }, - getResolvedConfig: () => getResolvedConfig(), - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getMpvClient: () => appState.mpvClient, - getAnkiIntegration: () => appState.ankiIntegration, - setAnkiIntegration: (integration: AnkiIntegration | null) => { - appState.ankiIntegration = integration; - }, - showDesktopNotification, - createFieldGroupingCallback: () => createFieldGroupingCallback(), - broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - getFieldGroupingResolver: () => getFieldGroupingResolver(), - setFieldGroupingResolver: ( - resolver: ((choice: KikuFieldGroupingChoice) => void) | null, - ) => setFieldGroupingResolver(resolver), - parseMediaInfo: (mediaPath: string | null) => - parseMediaInfo(resolveMediaPathForJimaku(mediaPath)), - getCurrentMediaPath: () => appState.currentMediaPath, - jimakuFetchJson: ( - endpoint: string, - query?: Record, - ): Promise> => - jimakuFetchJson(endpoint, query), - getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), - getJimakuLanguagePreference: () => getJimakuLanguagePreference(), - resolveJimakuApiKey: () => resolveJimakuApiKey(), - isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), - downloadToFile: ( - url: string, - destPath: string, - headers: Record, - ) => downloadToFile(url, destPath, headers), -}); +registerIpcRuntimeServices(buildIpcRuntimeServicesParams()); diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts new file mode 100644 index 0000000..2dd3a4f --- /dev/null +++ b/src/main/cli-runtime.ts @@ -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); +} + diff --git a/src/main/ipc-runtime.ts b/src/main/ipc-runtime.ts index c2197e7..a98e040 100644 --- a/src/main/ipc-runtime.ts +++ b/src/main/ipc-runtime.ts @@ -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); +} diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts new file mode 100644 index 0000000..3de4cde --- /dev/null +++ b/src/main/overlay-runtime.ts @@ -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; +} + +export function createOverlayModalRuntimeService( + deps: OverlayWindowResolver, +): OverlayModalRuntime { + const restoreVisibleOverlayOnModalClose = new Set(); + const overlayModalAutoShownLayer = new Map(); + + 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 }; diff --git a/src/main/state.ts b/src/main/state.ts new file mode 100644 index 0000000..8c9446c --- /dev/null +++ b/src/main/state.ts @@ -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 | null; + yomitanParserInitPromise: Promise | null; + mpvClient: MpvIpcClient | null; + reconnectTimer: ReturnType | 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; + 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; +} diff --git a/src/main/subsync-runtime.ts b/src/main/subsync-runtime.ts new file mode 100644 index 0000000..f6fb746 --- /dev/null +++ b/src/main/subsync-runtime.ts @@ -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 { + return triggerSubsyncFromConfigRuntimeService(createSubsyncRuntimeServiceDeps(params)); +} + +export async function runSubsyncManualFromIpcRuntime( + request: SubsyncManualRunRequest, + params: SubsyncRuntimeServiceInput, +): Promise { + return runSubsyncManualFromIpcRuntimeService( + request, + createSubsyncRuntimeServiceDeps(params), + ); +} diff --git a/vendor/texthooker-ui b/vendor/texthooker-ui index 3c11507..c969bf5 160000 --- a/vendor/texthooker-ui +++ b/vendor/texthooker-ui @@ -1 +1 @@ -Subproject commit 3c1150740abb768dc7319a19c51644ba3b09e9d7 +Subproject commit c969bf5a815951c380f8de6f3b806749c011ee25