From 65d9f5d54d4221a6516fe04584562e472bc3828b Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 14 Feb 2026 13:45:25 -0800 Subject: [PATCH] chore(main): extract app lifecycle/startup builders into main modules --- .gitignore | 1 + ...nd-migration-path-before-file-splitting.md | 8 +- ...t-main.ts-into-composition-root-modules.md | 24 +- ...ol-transport-property-and-facade-layers.md | 14 +- ...global-state-into-an-AppState-container.md | 11 +- docs/structure-roadmap.md | 12 +- package.json | 10 +- src/core/services/mpv-protocol.ts | 405 +++++++++++ src/core/services/mpv-service.test.ts | 121 ++-- src/core/services/mpv-service.ts | 641 +++++------------- src/main/app-lifecycle.ts | 89 +++ src/main/dependencies.ts | 252 +++++++ src/main/startup.ts | 63 ++ subminer | 20 +- 14 files changed, 1120 insertions(+), 551 deletions(-) create mode 100644 src/core/services/mpv-protocol.ts create mode 100644 src/main/app-lifecycle.ts create mode 100644 src/main/startup.ts diff --git a/.gitignore b/.gitignore index 85a2433..ff54f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ environment.toml .vitepress/dist/ docs/.vitepress/cache/ docs/.vitepress/dist/ +test/* diff --git a/backlog/tasks/task-27.1 - Map-component-ownership-boundaries-and-migration-path-before-file-splitting.md b/backlog/tasks/task-27.1 - Map-component-ownership-boundaries-and-migration-path-before-file-splitting.md index 0ec92ad..4a10841 100644 --- a/backlog/tasks/task-27.1 - Map-component-ownership-boundaries-and-migration-path-before-file-splitting.md +++ b/backlog/tasks/task-27.1 - Map-component-ownership-boundaries-and-migration-path-before-file-splitting.md @@ -1,11 +1,11 @@ --- id: TASK-27.1 title: Map component ownership boundaries and migration path before file splitting -status: To Do +status: Done assignee: - backend created_date: '2026-02-13 17:13' -updated_date: '2026-02-13 21:09' +updated_date: '2026-02-14 08:43' labels: - refactor - documentation @@ -13,7 +13,7 @@ dependencies: [] references: - docs/architecture.md documentation: - - docs/architecture.md + - docs/structure-roadmap.md parent_task_id: TASK-27 priority: high --- @@ -43,4 +43,6 @@ This is a documentation-only task — no code changes. Its output (docs/structur Original task called for named maintainer owners per component, risk-gated migration sequences, and success metrics per slice. This is heavyweight for a solo project where the developer already knows the codebase intimately. Reduced to: file inventory, API contracts, sequence + risks, and a shared smoke test checklist. The review analysis (in TASK-27 notes) already covers much of what this task would produce — this task captures it in a durable docs file. + +Generated docs/structure-roadmap.md with file inventory, task-specific API contracts, split sequence, known risks, and smoke checklist to unlock TASK-27 subtasks. diff --git a/backlog/tasks/task-27.2 - Split-main.ts-into-composition-root-modules.md b/backlog/tasks/task-27.2 - Split-main.ts-into-composition-root-modules.md index d181405..b927ff5 100644 --- a/backlog/tasks/task-27.2 - Split-main.ts-into-composition-root-modules.md +++ b/backlog/tasks/task-27.2 - Split-main.ts-into-composition-root-modules.md @@ -1,11 +1,11 @@ --- id: TASK-27.2 title: Split main.ts into composition-root modules -status: To Do +status: In Progress assignee: - backend created_date: '2026-02-13 17:13' -updated_date: '2026-02-14 02:23' +updated_date: '2026-02-14 09:41' labels: - 'owner:backend' - 'owner:architect' @@ -52,4 +52,24 @@ TASK-9 (remove trivial wrappers) and TASK-10 (naming conventions) have been depr - When main.ts is split into composition-root modules, trivial wrappers will naturally be eliminated or inlined at each module boundary. - Naming conventions should be standardized per-module as they are extracted, not as a separate global pass. Refer to TASK-9 and TASK-10 acceptance criteria as checklists during execution of this task. + +Deferred until TASK-7 validation and TASK-27.3 completion to avoid import-order/state scattering during composition-root extraction. + +Started `TASK-27.2` with a small composition-root extraction in `src/main.ts`: extracted `createMpvClientRuntimeService()` from inline `createMpvClient` deps object to reduce bootstrap-local complexity and prepare for module split (`main.ts` still owns state and startup sequencing remains unchanged). + +Added `createCliCommandRuntimeServiceDeps()` helper in `src/main.ts` and routed `handleCliCommand` through it, preserving existing `createCliCommandDepsRuntimeService` wiring while reducing inline dependency composition churn. + +Refactored `handleMpvCommandFromIpc` to use `createMpvCommandRuntimeServiceDeps()` helper, removing inline dependency object and keeping command dispatch behavior unchanged. + +Added `createAppLifecycleRuntimeDeps()` helper in `src/main.ts` and moved the full inline app-lifecycle dependency graph into it, so startup wiring now delegates to `createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps())` and the composition root is further decoupled from lifecycle behavior. + +Extracted startup bootstrap composition in `src/main.ts` by adding `createStartupBootstrapRuntimeDeps()`, replacing the inline `runStartupBootstrapRuntimeService({...})` object with a factory for parse/startup logging/config/bootstrap wiring. + +Fixed TS strict errors introduced by factory extractions by adding explicit runtime dependency interface annotations to factory helpers (`createStartupBootstrapRuntimeDeps`, `createAppLifecycleRuntimeDeps`, `createCliCommandRuntimeServiceDeps`, `createMpvCommandRuntimeServiceDeps`, `createMainIpcRuntimeServiceDeps`, `createAnkiJimakuIpcRuntimeServiceDeps`) and by typing `jimakuFetchJson` wrapper generically to satisfy `AnkiJimakuIpcRuntimeOptions`. + +Extracted app-ready startup dependency object into `createAppReadyRuntimeDeps(): AppReadyRuntimeDeps`, moving the inline `runAppReadyRuntimeService({...})` payload out of `createAppLifecycleRuntimeDeps()` while preserving behavior. + +Added `SubsyncRuntimeDeps` typing to `getSubsyncRuntimeDeps()` for clearer composition-root contracts around subsync IPC/dependency wiring (`runSubsyncManualFromIpcRuntimeService`/`triggerSubsyncFromConfigRuntimeService` path). + +Extracted additional composition-root dependency composition for IPC command handlers into src/main/dependencies.ts: createCliCommandRuntimeServiceDeps(...) and createMpvCommandRuntimeServiceDeps(...). main.ts now inlines stateful callbacks into these shared builders while preserving behavior. Next step should be extracting startup/app-ready/lifecycle/overlay wiring into dedicated modules under src/main/. diff --git a/backlog/tasks/task-27.4 - Split-mpv-service.ts-into-protocol-transport-property-and-facade-layers.md b/backlog/tasks/task-27.4 - Split-mpv-service.ts-into-protocol-transport-property-and-facade-layers.md index f99fb5e..b0bfe9d 100644 --- a/backlog/tasks/task-27.4 - Split-mpv-service.ts-into-protocol-transport-property-and-facade-layers.md +++ b/backlog/tasks/task-27.4 - Split-mpv-service.ts-into-protocol-transport-property-and-facade-layers.md @@ -1,11 +1,11 @@ --- id: TASK-27.4 title: 'Split mpv-service.ts into protocol, transport, property, and facade layers' -status: To Do +status: In Progress assignee: - backend created_date: '2026-02-13 17:13' -updated_date: '2026-02-13 21:15' +updated_date: '2026-02-14 21:19' labels: - 'owner:backend' dependencies: @@ -61,4 +61,14 @@ TASK-27.4 is TASK-8 + physical file splitting. Running them as separate tasks wo ## Dependency Note Original plan listed TASK-8 as a dependency. Since TASK-8's scope is now absorbed here, the only remaining dependency is TASK-27.1 (inventory/contracts map). + +Started prep: reviewed mpv-service coupling and prepared sequence for protocol/application split; no code split performed yet due current focus on keeping 27.2/27.3 sequencing compatible. + +Known compatibility constraint: TASK-27.4 should proceed only after main.ts AppState migration is stable and after the app-level overlay/subsync/anki behavior contracts are preserved. + +Milestone progress: extracted protocol buffer parsing into `src/core/services/mpv-protocol.ts`; `src/core/services/mpv-service.ts` now uses `splitMpvMessagesFromBuffer` in `processBuffer` and still delegates full message handling to existing handler. This is a small Phase B step toward protocol/dispatch separation. + +Protocol extraction completed: full `MpvMessage` handling moved into `src/core/services/mpv-protocol.ts` via `splitMpvMessagesFromBuffer` + `dispatchMpvProtocolMessage`; `MpvIpcClient` now delegates all message parsing/dispatch through `MpvProtocolHandleMessageDeps` and resolves pending requests through `tryResolvePendingRequest`. `main.ts` wiring remains unchanged. + +Updated `docs/structure-roadmap.md` expected mpv flow snapshot to reflect protocol parse/dispatch extraction (`splitMpvMessagesFromBuffer` + `dispatchMpvProtocolMessage`) and façade delegation path via `MpvProtocolHandleMessageDeps`. diff --git a/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md b/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md index a3b544b..11ca7de 100644 --- a/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md +++ b/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md @@ -1,9 +1,10 @@ --- id: TASK-7 title: Extract main.ts global state into an AppState container -status: To Do +status: In Progress assignee: [] created_date: '2026-02-11 08:20' +updated_date: '2026-02-14 08:45' labels: - refactor - main @@ -33,3 +34,11 @@ Consolidate into a typed AppState object (or small set of domain-specific state - [ ] #4 Dependency objects for services shrink significantly (reference the container instead) - [ ] #5 TypeScript compiles cleanly + +## Implementation Notes + + +Started centralizing module-level application state in `src/main.ts` via `appState` container and routing most state reads/writes through it. Initial rewrite completed; behavior verification pending and dependency-surface shrink pass still needed. + +Implemented Task-7 state migration to `appState` in main.ts and removed module-scope mutable state declarations; fixed a broken regression where several appState references were left as bare expressions in object literals (e.g., `appState.autoStartOverlay`), restoring valid typed dependency construction. + diff --git a/docs/structure-roadmap.md b/docs/structure-roadmap.md index 4249b22..ba31fd5 100644 --- a/docs/structure-roadmap.md +++ b/docs/structure-roadmap.md @@ -115,12 +115,12 @@ Adopted sequence (from TASK-27 parent): ### TASK-27.4 expected event flow snapshot (current) - `connect()` establishes socket and starts `observe_property` subscriptions via `subscribeToProperties()`. -- Protocol frames are dispatched through `processBuffer()` → `handleMessage()`. -- For `property-change` and request responses, handling remains in `MpvIpcClient` today but is a target for extraction: - - subtitle text/ASS/timing updates → `deps.setCurrentSubText`, `deps.setCurrentSubAssText`, subtitle timing tracker, overlay broadcast hooks - - media/path/title updates → `deps.updateCurrentMediaPath`, optional `deps.updateCurrentMediaTitle` - - property update commands (`sub-pos`, `sub-font*`, `sub-scale*`, etc.) → `deps.updateMpvSubtitleRenderMetrics` - - secondary-sub visibility tracking → `deps.setPreviousSecondarySubVisibility` + local `restorePreviousSecondarySubVisibility()` +- `processBuffer()` uses `splitMpvMessagesFromBuffer()` to parse JSON lines and route each message to `handleMessage()`. +- `dispatchMpvProtocolMessage()` now owns protocol-level handling for: + - `event === "property-change"` updates (subtitle text/ASS/timing, media/path/title, track metrics, secondary subtitles, visibility restore flags) + - `request_id` responses for startup state fetches and dynamic property queries + - shutdown handling and pending request resolution +- `handleMessage()` now delegates state mutation and side effects through `MpvProtocolHandleMessageDeps` to the client facade (`emit(...)`, state fields, `sendCommand`, etc.). - Reconnect path stays behavior-critical: - socket close/error clears pending requests and calls `scheduleReconnect()` - timer callback calls `connect()` after exponential-ish delay diff --git a/package.json b/package.json index d8238a7..33a5c22 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,13 @@ "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", - "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", - "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", + "test:config:dist": "node --test dist/config/config.test.js", + "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", + "test:subtitle:dist": "node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", + "test:config": "pnpm run build && pnpm run test:config:dist", + "test:core": "pnpm run build && pnpm run test:core:dist", + "test:subtitle": "pnpm run build && pnpm run test:subtitle:dist", + "test:fast": "pnpm run test:config:dist && pnpm run test:core:dist && pnpm run test:subtitle:dist", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", "dev": "pnpm run build && electron . --start --dev", diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts new file mode 100644 index 0000000..50dddeb --- /dev/null +++ b/src/core/services/mpv-protocol.ts @@ -0,0 +1,405 @@ +import { MpvSubtitleRenderMetrics } from "../../types"; + +export type MpvMessage = { + event?: string; + name?: string; + data?: unknown; + request_id?: number; + error?: string; +}; + +export const MPV_REQUEST_ID_SUBTEXT = 101; +export const MPV_REQUEST_ID_PATH = 102; +export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; +export const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104; +export const MPV_REQUEST_ID_AID = 105; +export const MPV_REQUEST_ID_SUB_POS = 106; +export const MPV_REQUEST_ID_SUB_FONT_SIZE = 107; +export const MPV_REQUEST_ID_SUB_SCALE = 108; +export const MPV_REQUEST_ID_SUB_MARGIN_Y = 109; +export const MPV_REQUEST_ID_SUB_MARGIN_X = 110; +export const MPV_REQUEST_ID_SUB_FONT = 111; +export const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112; +export const MPV_REQUEST_ID_OSD_HEIGHT = 113; +export const MPV_REQUEST_ID_OSD_DIMENSIONS = 114; +export const MPV_REQUEST_ID_SUBTEXT_ASS = 115; +export const MPV_REQUEST_ID_SUB_SPACING = 116; +export const MPV_REQUEST_ID_SUB_BOLD = 117; +export const MPV_REQUEST_ID_SUB_ITALIC = 118; +export const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119; +export const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; +export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; +export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; +export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; +export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; + +export type MpvMessageParser = (message: MpvMessage) => void; +export type MpvParseErrorHandler = ( + line: string, + error: unknown, +) => void; + +export interface MpvProtocolParseResult { + messages: MpvMessage[]; + nextBuffer: string; +} + +export interface MpvProtocolHandleMessageDeps { + getResolvedConfig: () => { secondarySub?: { secondarySubLanguages?: Array } }; + getSubtitleMetrics: () => MpvSubtitleRenderMetrics; + isVisibleOverlayVisible: () => boolean; + emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; + emitSubtitleAssChange: (payload: { text: string }) => void; + emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; + emitSecondarySubtitleChange: (payload: { text: string }) => void; + getCurrentSubText: () => string; + setCurrentSubText: (text: string) => void; + setCurrentSubStart: (value: number) => void; + getCurrentSubStart: () => number; + setCurrentSubEnd: (value: number) => void; + getCurrentSubEnd: () => number; + emitMediaPathChange: (payload: { path: string }) => void; + emitMediaTitleChange: (payload: { title: string | null }) => void; + emitSubtitleMetricsChange: (payload: Partial) => void; + setCurrentSecondarySubText: (text: string) => void; + resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; + setSecondarySubVisibility: (visible: boolean) => void; + syncCurrentAudioStreamIndex: () => void; + setCurrentAudioTrackId: (value: number | null) => void; + setCurrentTimePos: (value: number) => void; + getCurrentTimePos: () => number; + getPendingPauseAtSubEnd: () => boolean; + setPendingPauseAtSubEnd: (value: boolean) => void; + getPauseAtTime: () => number | null; + setPauseAtTime: (value: number | null) => void; + autoLoadSecondarySubTrack: () => void; + setCurrentVideoPath: (value: string) => void; + emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; + setCurrentAudioStreamIndex: ( + tracks: Array<{ + type?: string; + id?: number; + selected?: boolean; + "ff-index"?: number; + }>, + ) => void; + sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean; + restorePreviousSecondarySubVisibility: () => void; +} + +export function splitMpvMessagesFromBuffer( + buffer: string, + onMessage?: MpvMessageParser, + onError?: MpvParseErrorHandler, +): MpvProtocolParseResult { + const lines = buffer.split("\n"); + const nextBuffer = lines.pop() || ""; + const messages: MpvMessage[] = []; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const message = JSON.parse(line) as MpvMessage; + messages.push(message); + if (onMessage) { + onMessage(message); + } + } catch (error) { + if (onError) { + onError(line, error); + } + } + } + + return { + messages, + nextBuffer, + }; +} + +export async function dispatchMpvProtocolMessage( + msg: MpvMessage, + deps: MpvProtocolHandleMessageDeps, +): Promise { + if (msg.event === "property-change") { + if (msg.name === "sub-text") { + const nextSubText = (msg.data as string) || ""; + const overlayVisible = deps.isVisibleOverlayVisible(); + deps.emitSubtitleChange({ text: nextSubText, isOverlayVisible: overlayVisible }); + deps.setCurrentSubText(nextSubText); + } else if (msg.name === "sub-text-ass") { + deps.emitSubtitleAssChange({ text: (msg.data as string) || "" }); + } else if (msg.name === "sub-start") { + deps.setCurrentSubStart((msg.data as number) || 0); + deps.emitSubtitleTiming({ + text: deps.getCurrentSubText(), + start: deps.getCurrentSubStart(), + end: deps.getCurrentSubEnd(), + }); + } else if (msg.name === "sub-end") { + deps.setCurrentSubEnd((msg.data as number) || 0); + if (deps.getPendingPauseAtSubEnd() && deps.getCurrentSubEnd() > 0) { + deps.setPauseAtTime(deps.getCurrentSubEnd()); + deps.setPendingPauseAtSubEnd(false); + deps.sendCommand({ command: ["set_property", "pause", false] }); + } + deps.emitSubtitleTiming({ + text: deps.getCurrentSubText(), + start: deps.getCurrentSubStart(), + end: deps.getCurrentSubEnd(), + }); + } else if (msg.name === "secondary-sub-text") { + const nextSubText = (msg.data as string) || ""; + deps.setCurrentSecondarySubText(nextSubText); + deps.emitSecondarySubtitleChange({ text: nextSubText }); + } else if (msg.name === "aid") { + deps.setCurrentAudioTrackId( + typeof msg.data === "number" ? (msg.data as number) : null, + ); + deps.syncCurrentAudioStreamIndex(); + } else if (msg.name === "time-pos") { + deps.setCurrentTimePos((msg.data as number) || 0); + if ( + deps.getPauseAtTime() !== null && + deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number) + ) { + deps.setPauseAtTime(null); + deps.sendCommand({ command: ["set_property", "pause", true] }); + } + } else if (msg.name === "media-title") { + deps.emitMediaTitleChange({ + title: typeof msg.data === "string" ? msg.data.trim() : null, + }); + } else if (msg.name === "path") { + const path = (msg.data as string) || ""; + deps.setCurrentVideoPath(path); + deps.emitMediaPathChange({ path }); + deps.autoLoadSecondarySubTrack(); + deps.syncCurrentAudioStreamIndex(); + } else if (msg.name === "sub-pos") { + deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); + } else if (msg.name === "sub-font-size") { + deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number }); + } else if (msg.name === "sub-scale") { + deps.emitSubtitleMetricsChange({ subScale: msg.data as number }); + } else if (msg.name === "sub-margin-y") { + deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number }); + } else if (msg.name === "sub-margin-x") { + deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number }); + } else if (msg.name === "sub-font") { + deps.emitSubtitleMetricsChange({ subFont: msg.data as string }); + } else if (msg.name === "sub-spacing") { + deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number }); + } else if (msg.name === "sub-bold") { + deps.emitSubtitleMetricsChange({ + subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold), + }); + } else if (msg.name === "sub-italic") { + deps.emitSubtitleMetricsChange({ + subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic), + }); + } else if (msg.name === "sub-border-size") { + deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number }); + } else if (msg.name === "sub-shadow-offset") { + deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number }); + } else if (msg.name === "sub-ass-override") { + deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string }); + } else if (msg.name === "sub-scale-by-window") { + deps.emitSubtitleMetricsChange({ + subScaleByWindow: asBoolean( + msg.data, + deps.getSubtitleMetrics().subScaleByWindow, + ), + }); + } else if (msg.name === "sub-use-margins") { + deps.emitSubtitleMetricsChange({ + subUseMargins: asBoolean( + msg.data, + deps.getSubtitleMetrics().subUseMargins, + ), + }); + } else if (msg.name === "osd-height") { + deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number }); + } else if (msg.name === "osd-dimensions") { + const dims = msg.data as Record | null; + if (!dims) { + deps.emitSubtitleMetricsChange({ osdDimensions: null }); + } else { + deps.emitSubtitleMetricsChange({ + osdDimensions: { + w: asFiniteNumber(dims.w, 0), + h: asFiniteNumber(dims.h, 0), + ml: asFiniteNumber(dims.ml, 0), + mr: asFiniteNumber(dims.mr, 0), + mt: asFiniteNumber(dims.mt, 0), + mb: asFiniteNumber(dims.mb, 0), + }, + }); + } + } + } else if (msg.event === "shutdown") { + deps.restorePreviousSecondarySubVisibility(); + } else if (msg.request_id) { + if (deps.resolvePendingRequest(msg.request_id, msg)) { + return; + } + + if (msg.data === undefined) { + return; + } + + if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) { + const tracks = msg.data as Array<{ + type: string; + lang?: string; + id: number; + }>; + if (Array.isArray(tracks)) { + const config = deps.getResolvedConfig(); + const languages = config.secondarySub?.secondarySubLanguages || []; + const subTracks = tracks.filter((track) => track.type === "sub"); + for (const language of languages) { + const match = subTracks.find((track) => track.lang === language); + if (match) { + deps.sendCommand({ + command: ["set_property", "secondary-sid", match.id], + }); + break; + } + } + } + } else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) { + deps.setCurrentAudioStreamIndex( + msg.data as Array<{ + type?: string; + id?: number; + selected?: boolean; + "ff-index"?: number; + }>, + ); + } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) { + const nextSubText = (msg.data as string) || ""; + deps.setCurrentSubText(nextSubText); + deps.emitSubtitleChange({ + text: nextSubText, + isOverlayVisible: deps.isVisibleOverlayVisible(), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) { + deps.emitSubtitleAssChange({ text: (msg.data as string) || "" }); + } else if (msg.request_id === MPV_REQUEST_ID_PATH) { + deps.emitMediaPathChange({ path: (msg.data as string) || "" }); + } else if (msg.request_id === MPV_REQUEST_ID_AID) { + deps.setCurrentAudioTrackId( + typeof msg.data === "number" ? (msg.data as number) : null, + ); + deps.syncCurrentAudioStreamIndex(); + } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) { + const nextSubText = (msg.data as string) || ""; + deps.setCurrentSecondarySubText(nextSubText); + deps.emitSecondarySubtitleChange({ text: nextSubText }); + } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) { + const previous = parseVisibilityProperty(msg.data); + if (previous !== null) { + deps.emitSecondarySubtitleVisibility({ visible: previous }); + } + deps.setSecondarySubVisibility(false); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) { + deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) { + deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) { + deps.emitSubtitleMetricsChange({ subScale: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) { + deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) { + deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) { + deps.emitSubtitleMetricsChange({ subFont: msg.data as string }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) { + deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) { + deps.emitSubtitleMetricsChange({ + subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) { + deps.emitSubtitleMetricsChange({ + subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) { + deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) { + deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) { + deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) { + deps.emitSubtitleMetricsChange({ + subScaleByWindow: asBoolean( + msg.data, + deps.getSubtitleMetrics().subScaleByWindow, + ), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) { + deps.emitSubtitleMetricsChange({ + subUseMargins: asBoolean( + msg.data, + deps.getSubtitleMetrics().subUseMargins, + ), + }); + } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) { + deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { + const dims = msg.data as Record | null; + if (!dims) { + deps.emitSubtitleMetricsChange({ osdDimensions: null }); + } else { + deps.emitSubtitleMetricsChange({ + osdDimensions: { + w: asFiniteNumber(dims.w, 0), + h: asFiniteNumber(dims.h, 0), + ml: asFiniteNumber(dims.ml, 0), + mr: asFiniteNumber(dims.mr, 0), + mt: asFiniteNumber(dims.mt, 0), + mb: asFiniteNumber(dims.mb, 0), + }, + }); + } + } + } +} + +export function asBoolean( + value: unknown, + fallback: boolean, +): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["yes", "true", "1"].includes(normalized)) return true; + if (["no", "false", "0"].includes(normalized)) return false; + } + return fallback; +} + +export function asFiniteNumber( + value: unknown, + fallback: number, +): number { + const nextValue = Number(value); + return Number.isFinite(nextValue) ? nextValue : fallback; +} + +export function parseVisibilityProperty(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + + const normalized = value.trim().toLowerCase(); + if (normalized === "yes" || normalized === "true" || normalized === "1") { + return true; + } + if (normalized === "no" || normalized === "false" || normalized === "0") { + return false; + } + + return null; +} diff --git a/src/core/services/mpv-service.test.ts b/src/core/services/mpv-service.test.ts index a9685cc..d0ce67d 100644 --- a/src/core/services/mpv-service.test.ts +++ b/src/core/services/mpv-service.test.ts @@ -3,11 +3,12 @@ import assert from "node:assert/strict"; import { MpvIpcClient, MpvIpcClientDeps, + MpvIpcClientProtocolDeps, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, } from "./mpv-service"; function makeDeps( - overrides: Partial = {}, + overrides: Partial = {}, ): MpvIpcClientDeps { return { getResolvedConfig: () => ({} as any), @@ -17,41 +18,16 @@ function makeDeps( isVisibleOverlayVisible: () => false, getReconnectTimer: () => null, setReconnectTimer: () => {}, - getCurrentSubText: () => "", - setCurrentSubText: () => {}, - setCurrentSubAssText: () => {}, - getSubtitleTimingTracker: () => null, - subtitleWsBroadcast: () => {}, - getOverlayWindowsCount: () => 0, - tokenizeSubtitle: async (text) => ({ text, tokens: null }), - broadcastToOverlayWindows: () => {}, - updateCurrentMediaPath: () => {}, - updateMpvSubtitleRenderMetrics: () => {}, - getMpvSubtitleRenderMetrics: () => ({ - subPos: 100, - subFontSize: 36, - subScale: 1, - subMarginY: 0, - subMarginX: 0, - subFont: "sans-serif", - subSpacing: 0, - subBold: false, - subItalic: false, - subBorderSize: 0, - subShadowOffset: 0, - subAssOverride: "yes", - subScaleByWindow: true, - subUseMargins: true, - osdHeight: 720, - osdDimensions: null, - }), - getPreviousSecondarySubVisibility: () => null, - setPreviousSecondarySubVisibility: () => {}, - showMpvOsd: () => {}, ...overrides, }; } +function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise { + return (client as unknown as { handleMessage: (msg: unknown) => Promise }).handleMessage( + msg, + ); +} + test("MpvIpcClient resolves pending request by request_id", async () => { const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); let resolved: unknown = null; @@ -59,40 +35,28 @@ test("MpvIpcClient resolves pending request by request_id", async () => { resolved = msg; }); - await (client as any).handleMessage({ request_id: 1234, data: "ok" }); + await invokeHandleMessage(client, { request_id: 1234, data: "ok" }); assert.deepEqual(resolved, { request_id: 1234, data: "ok" }); assert.equal((client as any).pendingRequests.size, 0); }); test("MpvIpcClient handles sub-text property change and broadcasts tokenized subtitle", async () => { - const calls: string[] = []; - const client = new MpvIpcClient( - "/tmp/mpv.sock", - makeDeps({ - setCurrentSubText: (text) => { - calls.push(`setCurrentSubText:${text}`); - }, - subtitleWsBroadcast: (text) => { - calls.push(`subtitleWsBroadcast:${text}`); - }, - getOverlayWindowsCount: () => 1, - tokenizeSubtitle: async (text) => ({ text, tokens: null }), - broadcastToOverlayWindows: (channel, payload) => { - calls.push(`broadcast:${channel}:${String((payload as any).text ?? "")}`); - }, - }), - ); + const events: Array<{ text: string; isOverlayVisible: boolean }> = []; + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + client.on("subtitle-change", (payload) => { + events.push(payload); + }); - await (client as any).handleMessage({ + await invokeHandleMessage(client, { event: "property-change", name: "sub-text", data: "字幕", }); - assert.ok(calls.includes("setCurrentSubText:字幕")); - assert.ok(calls.includes("subtitleWsBroadcast:字幕")); - assert.ok(calls.includes("broadcast:subtitle:set:字幕")); + assert.equal(events.length, 1); + assert.equal(events[0].text, "字幕"); + assert.equal(events[0].isOverlayVisible, false); }); test("MpvIpcClient parses JSON line protocol in processBuffer", () => { @@ -182,28 +146,23 @@ test("MpvIpcClient scheduleReconnect schedules timer and invokes connect", () => test("MpvIpcClient captures and disables secondary subtitle visibility on request", async () => { const commands: unknown[] = []; - let previousSecondarySubVisibility: boolean | null = null; - const client = new MpvIpcClient( - "/tmp/mpv.sock", - makeDeps({ - getPreviousSecondarySubVisibility: () => previousSecondarySubVisibility, - setPreviousSecondarySubVisibility: (value) => { - previousSecondarySubVisibility = value; - }, - }), - ); + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + const previous: boolean[] = []; + client.on("secondary-subtitle-visibility", ({ visible }) => { + previous.push(visible); + }); (client as any).send = (payload: unknown) => { commands.push(payload); return true; }; - await (client as any).handleMessage({ + await invokeHandleMessage(client, { request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, data: "yes", }); - assert.equal(previousSecondarySubVisibility, true); + assert.deepEqual(previous, [true]); assert.deepEqual(commands, [ { command: ["set_property", "secondary-sub-visibility", "no"], @@ -211,30 +170,36 @@ test("MpvIpcClient captures and disables secondary subtitle visibility on reques ]); }); -test("MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value", () => { +test("MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value", async () => { const commands: unknown[] = []; - let previousSecondarySubVisibility: boolean | null = false; - const client = new MpvIpcClient( - "/tmp/mpv.sock", - makeDeps({ - getPreviousSecondarySubVisibility: () => previousSecondarySubVisibility, - setPreviousSecondarySubVisibility: (value) => { - previousSecondarySubVisibility = value; - }, - }), - ); + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + const previous: boolean[] = []; + client.on("secondary-subtitle-visibility", ({ visible }) => { + previous.push(visible); + }); (client as any).send = (payload: unknown) => { commands.push(payload); return true; }; + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + data: "yes", + }); client.restorePreviousSecondarySubVisibility(); + assert.equal(previous[0], true); + assert.equal(previous.length, 1); assert.deepEqual(commands, [ { command: ["set_property", "secondary-sub-visibility", "no"], }, + { + command: ["set_property", "secondary-sub-visibility", "no"], + }, ]); - assert.equal(previousSecondarySubVisibility, null); + + client.restorePreviousSecondarySubVisibility(); + assert.equal(commands.length, 2); }); diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts index 8da09fc..2608061 100644 --- a/src/core/services/mpv-service.ts +++ b/src/core/services/mpv-service.ts @@ -4,46 +4,41 @@ import { Config, MpvClient, MpvSubtitleRenderMetrics, - SubtitleData, } from "../../types"; -import { asBoolean, asFiniteNumber } from "../utils/coerce"; +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"; -interface MpvMessage { - event?: string; - name?: string; - data?: unknown; - request_id?: number; - error?: string; -} - -const MPV_REQUEST_ID_SUBTEXT = 101; -const MPV_REQUEST_ID_PATH = 102; -const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; -export const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104; -const MPV_REQUEST_ID_AID = 105; -const MPV_REQUEST_ID_SUB_POS = 106; -const MPV_REQUEST_ID_SUB_FONT_SIZE = 107; -const MPV_REQUEST_ID_SUB_SCALE = 108; -const MPV_REQUEST_ID_SUB_MARGIN_Y = 109; -const MPV_REQUEST_ID_SUB_MARGIN_X = 110; -const MPV_REQUEST_ID_SUB_FONT = 111; -const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112; -const MPV_REQUEST_ID_OSD_HEIGHT = 113; -const MPV_REQUEST_ID_OSD_DIMENSIONS = 114; -const MPV_REQUEST_ID_SUBTEXT_ASS = 115; -const MPV_REQUEST_ID_SUB_SPACING = 116; -const MPV_REQUEST_ID_SUB_BOLD = 117; -const MPV_REQUEST_ID_SUB_ITALIC = 118; -const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119; -const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; -const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; -const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; -const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; -const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; - -interface SubtitleTimingTrackerLike { - recordSubtitle: (text: string, start: number, end: number) => void; -} +export { + MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, +} from "./mpv-protocol"; export interface MpvIpcClientProtocolDeps { getResolvedConfig: () => Config; @@ -55,31 +50,12 @@ export interface MpvIpcClientProtocolDeps { setReconnectTimer: (timer: ReturnType | null) => void; } -export interface MpvIpcClientRuntimeDeps { - getCurrentSubText?: () => string; - setCurrentSubText?: (text: string) => void; - setCurrentSubAssText?: (text: string) => void; - getSubtitleTimingTracker?: () => SubtitleTimingTrackerLike | null; - subtitleWsBroadcast?: (text: string) => void; - getOverlayWindowsCount?: () => number; - tokenizeSubtitle?: (text: string) => Promise; - broadcastToOverlayWindows?: (channel: string, ...args: unknown[]) => void; - updateCurrentMediaPath?: (mediaPath: unknown) => void; - updateMpvSubtitleRenderMetrics?: ( - patch: Partial, - ) => void; - getMpvSubtitleRenderMetrics?: () => MpvSubtitleRenderMetrics; - getPreviousSecondarySubVisibility?: () => boolean | null; - setPreviousSecondarySubVisibility?: (value: boolean | null) => void; - showMpvOsd?: (text: string) => void; - updateCurrentMediaTitle?: (mediaTitle: unknown) => void; -} - -export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps, MpvIpcClientRuntimeDeps {} +export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} export interface MpvIpcClientEventMap { "subtitle-change": { text: string; isOverlayVisible: boolean }; "subtitle-ass-change": { text: string }; + "subtitle-timing": { text: string; start: number; end: number }; "secondary-subtitle-change": { text: string }; "media-path-change": { path: string }; "media-title-change": { title: string | null }; @@ -91,7 +67,7 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap; export class MpvIpcClient implements MpvClient { private socketPath: string; - private deps: MpvIpcClientProtocolDeps & Required; + private deps: MpvIpcClientProtocolDeps; public socket: net.Socket | null = null; private eventBus = new EventEmitter(); private buffer = ""; @@ -108,48 +84,36 @@ export class MpvIpcClient implements MpvClient { public currentSecondarySubText = ""; public currentAudioStreamIndex: number | null = null; private currentAudioTrackId: number | null = null; + private mpvSubtitleRenderMetrics: 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, + }; + private previousSecondarySubVisibility: boolean | null = null; private pauseAtTime: number | null = null; private pendingPauseAtSubEnd = false; private nextDynamicRequestId = 1000; private pendingRequests = new Map void>(); - constructor(socketPath: string, deps: MpvIpcClientDeps) { + constructor( + socketPath: string, + deps: MpvIpcClientDeps, + ) { this.socketPath = socketPath; - this.deps = { - getCurrentSubText: () => "", - setCurrentSubText: () => undefined, - setCurrentSubAssText: () => undefined, - getSubtitleTimingTracker: () => null, - subtitleWsBroadcast: () => undefined, - getOverlayWindowsCount: () => 0, - tokenizeSubtitle: async (text) => ({ text, tokens: null }), - broadcastToOverlayWindows: () => undefined, - updateCurrentMediaPath: () => undefined, - updateCurrentMediaTitle: () => undefined, - updateMpvSubtitleRenderMetrics: () => undefined, - getMpvSubtitleRenderMetrics: () => ({ - 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, - }), - getPreviousSecondarySubVisibility: () => null, - setPreviousSecondarySubVisibility: () => undefined, - showMpvOsd: () => undefined, - ...deps, - }; + this.deps = deps; } on( @@ -173,6 +137,16 @@ export class MpvIpcClient implements MpvClient { this.eventBus.emit(event as string, payload); } + private emitSubtitleMetricsChange( + patch: Partial, + ): void { + this.mpvSubtitleRenderMetrics = { + ...this.mpvSubtitleRenderMetrics, + ...patch, + }; + this.emit("subtitle-metrics-change", { patch }); + } + setSocketPath(socketPath: string): void { this.socketPath = socketPath; } @@ -195,6 +169,7 @@ export class MpvIpcClient implements MpvClient { this.connecting = false; this.reconnectAttempt = 0; this.hasConnectedOnce = true; + this.setSecondarySubVisibility(false); this.subscribeToProperties(); this.getInitialState(); @@ -275,367 +250,102 @@ export class MpvIpcClient implements MpvClient { } private processBuffer(): void { - const lines = this.buffer.split("\n"); - this.buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line) as MpvMessage; - this.handleMessage(msg); - } catch (e) { - console.error("Failed to parse MPV message:", line, e); - } - } + const parsed = splitMpvMessagesFromBuffer( + this.buffer, + (message) => { + this.handleMessage(message); + }, + (line, error) => { + console.error("Failed to parse MPV message:", line, error); + }, + ); + this.buffer = parsed.nextBuffer; } private async handleMessage(msg: MpvMessage): Promise { - if (msg.event === "property-change") { - if (msg.name === "sub-text") { - const nextSubText = (msg.data as string) || ""; - const overlayVisible = this.deps.isVisibleOverlayVisible(); - this.emit("subtitle-change", { - text: nextSubText, - isOverlayVisible: overlayVisible, - }); - this.deps.setCurrentSubText(nextSubText); - this.currentSubText = nextSubText; - const subtitleTimingTracker = this.deps.getSubtitleTimingTracker(); - if ( - subtitleTimingTracker && - this.currentSubStart !== undefined && - this.currentSubEnd !== undefined - ) { - subtitleTimingTracker.recordSubtitle( - nextSubText, - this.currentSubStart, - this.currentSubEnd, - ); - } - this.deps.subtitleWsBroadcast(nextSubText); - if (this.deps.getOverlayWindowsCount() > 0) { - const subtitleData = await this.deps.tokenizeSubtitle(nextSubText); - this.deps.broadcastToOverlayWindows("subtitle:set", subtitleData); - } - } else if (msg.name === "sub-text-ass") { - const nextSubAssText = (msg.data as string) || ""; - this.emit("subtitle-ass-change", { - text: nextSubAssText, - }); - this.deps.setCurrentSubAssText(nextSubAssText); - this.deps.broadcastToOverlayWindows("subtitle-ass:set", nextSubAssText); - } else if (msg.name === "sub-start") { - this.currentSubStart = (msg.data as number) || 0; - const subtitleTimingTracker = this.deps.getSubtitleTimingTracker(); - if (subtitleTimingTracker && this.deps.getCurrentSubText()) { - subtitleTimingTracker.recordSubtitle( - this.deps.getCurrentSubText(), - this.currentSubStart, - this.currentSubEnd, - ); - } - } else if (msg.name === "sub-end") { - this.currentSubEnd = (msg.data as number) || 0; - if (this.pendingPauseAtSubEnd && this.currentSubEnd > 0) { - this.pauseAtTime = this.currentSubEnd; - this.pendingPauseAtSubEnd = false; - this.send({ command: ["set_property", "pause", false] }); - } - const subtitleTimingTracker = this.deps.getSubtitleTimingTracker(); - if (subtitleTimingTracker && this.deps.getCurrentSubText()) { - subtitleTimingTracker.recordSubtitle( - this.deps.getCurrentSubText(), - this.currentSubStart, - this.currentSubEnd, - ); - } - } else if (msg.name === "secondary-sub-text") { - this.currentSecondarySubText = (msg.data as string) || ""; - this.emit("secondary-subtitle-change", { - text: this.currentSecondarySubText, - }); - this.deps.broadcastToOverlayWindows( - "secondary-subtitle:set", - this.currentSecondarySubText, - ); - } else if (msg.name === "aid") { - this.currentAudioTrackId = - typeof msg.data === "number" ? (msg.data as number) : null; + await dispatchMpvProtocolMessage(msg, this.createProtocolMessageDeps()); + } + + private createProtocolMessageDeps(): MpvProtocolHandleMessageDeps { + return { + getResolvedConfig: () => this.deps.getResolvedConfig(), + getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics, + isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(), + emitSubtitleChange: (payload) => { + this.emit("subtitle-change", payload); + }, + emitSubtitleAssChange: (payload) => { + this.emit("subtitle-ass-change", payload); + }, + emitSubtitleTiming: (payload) => { + this.emit("subtitle-timing", payload); + }, + emitSecondarySubtitleChange: (payload) => { + this.emit("secondary-subtitle-change", payload); + }, + getCurrentSubText: () => this.currentSubText, + setCurrentSubText: (text: string) => { + this.currentSubText = text; + }, + setCurrentSubStart: (value: number) => { + this.currentSubStart = value; + }, + getCurrentSubStart: () => this.currentSubStart, + setCurrentSubEnd: (value: number) => { + this.currentSubEnd = value; + }, + getCurrentSubEnd: () => this.currentSubEnd, + emitMediaPathChange: (payload) => { + this.emit("media-path-change", payload); + }, + emitMediaTitleChange: (payload) => { + this.emit("media-title-change", payload); + }, + emitSubtitleMetricsChange: (patch) => { + this.emitSubtitleMetricsChange(patch); + }, + setCurrentSecondarySubText: (text: string) => { + this.currentSecondarySubText = text; + }, + resolvePendingRequest: (requestId: number, message: MpvMessage) => + this.tryResolvePendingRequest(requestId, message), + setSecondarySubVisibility: (visible: boolean) => + this.setSecondarySubVisibility(visible), + syncCurrentAudioStreamIndex: () => { this.syncCurrentAudioStreamIndex(); - } else if (msg.name === "time-pos") { - this.currentTimePos = (msg.data as number) || 0; - if ( - this.pauseAtTime !== null && - this.currentTimePos >= this.pauseAtTime - ) { - this.pauseAtTime = null; - this.send({ command: ["set_property", "pause", true] }); - } - } else if (msg.name === "media-title") { - this.emit("media-title-change", { - title: typeof msg.data === "string" ? msg.data.trim() : null, - }); - this.deps.updateCurrentMediaTitle?.(msg.data); - } else if (msg.name === "path") { - this.currentVideoPath = (msg.data as string) || ""; - this.emit("media-path-change", { - path: (msg.data as string) || "", - }); - this.deps.updateCurrentMediaPath(msg.data); + }, + setCurrentAudioTrackId: (value: number | null) => { + this.currentAudioTrackId = value; + }, + setCurrentTimePos: (value: number) => { + this.currentTimePos = value; + }, + getCurrentTimePos: () => this.currentTimePos, + getPendingPauseAtSubEnd: () => this.pendingPauseAtSubEnd, + setPendingPauseAtSubEnd: (value: boolean) => { + this.pendingPauseAtSubEnd = value; + }, + getPauseAtTime: () => this.pauseAtTime, + setPauseAtTime: (value: number | null) => { + this.pauseAtTime = value; + }, + autoLoadSecondarySubTrack: () => { this.autoLoadSecondarySubTrack(); - this.syncCurrentAudioStreamIndex(); - } else if (msg.name === "sub-pos") { - const patch = { subPos: msg.data as number }; - this.emit("subtitle-metrics-change", { patch }); - this.deps.updateMpvSubtitleRenderMetrics({ subPos: msg.data as number }); - } else if (msg.name === "sub-font-size") { - this.deps.updateMpvSubtitleRenderMetrics({ - subFontSize: msg.data as number, - }); - } else if (msg.name === "sub-scale") { - this.deps.updateMpvSubtitleRenderMetrics({ subScale: msg.data as number }); - } else if (msg.name === "sub-margin-y") { - this.deps.updateMpvSubtitleRenderMetrics({ - subMarginY: msg.data as number, - }); - } else if (msg.name === "sub-margin-x") { - this.deps.updateMpvSubtitleRenderMetrics({ - subMarginX: msg.data as number, - }); - } else if (msg.name === "sub-font") { - this.deps.updateMpvSubtitleRenderMetrics({ subFont: msg.data as string }); - } else if (msg.name === "sub-spacing") { - this.deps.updateMpvSubtitleRenderMetrics({ - subSpacing: msg.data as number, - }); - } else if (msg.name === "sub-bold") { - this.deps.updateMpvSubtitleRenderMetrics({ - subBold: asBoolean(msg.data, this.deps.getMpvSubtitleRenderMetrics().subBold), - }); - } else if (msg.name === "sub-italic") { - this.deps.updateMpvSubtitleRenderMetrics({ - subItalic: asBoolean( - msg.data, - this.deps.getMpvSubtitleRenderMetrics().subItalic, - ), - }); - } else if (msg.name === "sub-border-size") { - this.deps.updateMpvSubtitleRenderMetrics({ - subBorderSize: msg.data as number, - }); - } else if (msg.name === "sub-shadow-offset") { - this.deps.updateMpvSubtitleRenderMetrics({ - subShadowOffset: msg.data as number, - }); - } else if (msg.name === "sub-ass-override") { - this.deps.updateMpvSubtitleRenderMetrics({ - subAssOverride: msg.data as string, - }); - } else if (msg.name === "sub-scale-by-window") { - this.deps.updateMpvSubtitleRenderMetrics({ - subScaleByWindow: asBoolean( - msg.data, - this.deps.getMpvSubtitleRenderMetrics().subScaleByWindow, - ), - }); - } else if (msg.name === "sub-use-margins") { - this.deps.updateMpvSubtitleRenderMetrics({ - subUseMargins: asBoolean( - msg.data, - this.deps.getMpvSubtitleRenderMetrics().subUseMargins, - ), - }); - } else if (msg.name === "osd-height") { - this.deps.updateMpvSubtitleRenderMetrics({ - osdHeight: msg.data as number, - }); - } else if (msg.name === "osd-dimensions") { - const dims = msg.data as Record | null; - if (!dims) { - this.deps.updateMpvSubtitleRenderMetrics({ osdDimensions: null }); - } else { - this.deps.updateMpvSubtitleRenderMetrics({ - osdDimensions: { - w: asFiniteNumber(dims.w, 0), - h: asFiniteNumber(dims.h, 0), - ml: asFiniteNumber(dims.ml, 0), - mr: asFiniteNumber(dims.mr, 0), - mt: asFiniteNumber(dims.mt, 0), - mb: asFiniteNumber(dims.mb, 0), - }, - }); - } - } - } else if (msg.event === "shutdown") { - this.restorePreviousSecondarySubVisibility(); - } else if (msg.request_id) { - const pending = this.pendingRequests.get(msg.request_id); - if (pending) { - this.pendingRequests.delete(msg.request_id); - pending(msg); - return; - } - - if (msg.data === undefined) { - return; - } - - if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) { - const tracks = msg.data as Array<{ - type: string; - lang?: string; - id: number; - }>; - if (Array.isArray(tracks)) { - const config = this.deps.getResolvedConfig(); - const languages = config.secondarySub?.secondarySubLanguages || []; - const subTracks = tracks.filter((t) => t.type === "sub"); - for (const lang of languages) { - const match = subTracks.find((t) => t.lang === lang); - if (match) { - this.send({ - command: ["set_property", "secondary-sid", match.id], - }); - // this.deps.showMpvOsd( - // `Secondary subtitle: ${lang} (track ${match.id})`, - // ); - break; - } - } - } - } else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) { - this.updateCurrentAudioStreamIndex( - msg.data as Array<{ - type?: string; - id?: number; - selected?: boolean; - "ff-index"?: number; - }>, - ); - } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) { - const nextSubText = (msg.data as string) || ""; - this.deps.setCurrentSubText(nextSubText); - this.currentSubText = nextSubText; - this.emit("subtitle-change", { - text: nextSubText, - isOverlayVisible: this.deps.isVisibleOverlayVisible(), - }); - this.deps.subtitleWsBroadcast(nextSubText); - if (this.deps.getOverlayWindowsCount() > 0) { - this.deps.tokenizeSubtitle(nextSubText).then((subtitleData) => { - this.deps.broadcastToOverlayWindows("subtitle:set", subtitleData); - }); - } - } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) { - const nextSubAssText = (msg.data as string) || ""; - this.emit("subtitle-ass-change", { - text: nextSubAssText, - }); - this.deps.setCurrentSubAssText(nextSubAssText); - this.deps.broadcastToOverlayWindows("subtitle-ass:set", nextSubAssText); - } else if (msg.request_id === MPV_REQUEST_ID_PATH) { - this.emit("media-path-change", { - path: (msg.data as string) || "", - }); - this.deps.updateCurrentMediaPath(msg.data); - } else if (msg.request_id === MPV_REQUEST_ID_AID) { - this.currentAudioTrackId = - typeof msg.data === "number" ? (msg.data as number) : null; - this.syncCurrentAudioStreamIndex(); - } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) { - this.currentSecondarySubText = (msg.data as string) || ""; - this.deps.broadcastToOverlayWindows( - "secondary-subtitle:set", - this.currentSecondarySubText, - ); - } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) { - this.deps.setPreviousSecondarySubVisibility( - msg.data === true || msg.data === "yes", - ); - this.send({ - command: ["set_property", "secondary-sub-visibility", "no"], - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) { - this.deps.updateMpvSubtitleRenderMetrics({ subPos: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) { - this.deps.updateMpvSubtitleRenderMetrics({ - subFontSize: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) { - this.deps.updateMpvSubtitleRenderMetrics({ subScale: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) { - this.deps.updateMpvSubtitleRenderMetrics({ - subMarginY: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) { - this.deps.updateMpvSubtitleRenderMetrics({ - subMarginX: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) { - this.deps.updateMpvSubtitleRenderMetrics({ subFont: msg.data as string }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) { - this.deps.updateMpvSubtitleRenderMetrics({ - subSpacing: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) { - this.deps.updateMpvSubtitleRenderMetrics({ - subBold: asBoolean(msg.data, this.deps.getMpvSubtitleRenderMetrics().subBold), - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) { - this.deps.updateMpvSubtitleRenderMetrics({ - subItalic: asBoolean( - msg.data, - this.deps.getMpvSubtitleRenderMetrics().subItalic, - ), - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) { - this.deps.updateMpvSubtitleRenderMetrics({ - subBorderSize: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) { - this.deps.updateMpvSubtitleRenderMetrics({ - subShadowOffset: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) { - this.deps.updateMpvSubtitleRenderMetrics({ - subAssOverride: msg.data as string, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) { - this.deps.updateMpvSubtitleRenderMetrics({ - subScaleByWindow: asBoolean( - msg.data, - this.deps.getMpvSubtitleRenderMetrics().subScaleByWindow, - ), - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) { - this.deps.updateMpvSubtitleRenderMetrics({ - subUseMargins: asBoolean( - msg.data, - this.deps.getMpvSubtitleRenderMetrics().subUseMargins, - ), - }); - } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) { - this.deps.updateMpvSubtitleRenderMetrics({ - osdHeight: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { - const dims = msg.data as Record | null; - if (!dims) { - this.deps.updateMpvSubtitleRenderMetrics({ osdDimensions: null }); - } else { - this.deps.updateMpvSubtitleRenderMetrics({ - osdDimensions: { - w: asFiniteNumber(dims.w, 0), - h: asFiniteNumber(dims.h, 0), - ml: asFiniteNumber(dims.ml, 0), - mr: asFiniteNumber(dims.mr, 0), - mt: asFiniteNumber(dims.mt, 0), - mb: asFiniteNumber(dims.mb, 0), - }, - }); - } - } - } + }, + setCurrentVideoPath: (value: string) => { + this.currentVideoPath = value; + }, + emitSecondarySubtitleVisibility: (payload) => { + this.emit("secondary-subtitle-visibility", payload); + }, + setCurrentAudioStreamIndex: (tracks) => { + this.updateCurrentAudioStreamIndex(tracks); + }, + sendCommand: (payload) => this.send(payload), + restorePreviousSecondarySubVisibility: () => { + this.restorePreviousSecondarySubVisibility(); + }, + }; } private autoLoadSecondarySubTrack(): void { @@ -734,6 +444,19 @@ export class MpvIpcClient implements MpvClient { this.pendingRequests.clear(); } + private tryResolvePendingRequest( + requestId: number, + message: MpvMessage, + ): boolean { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return false; + } + this.pendingRequests.delete(requestId); + pending(message); + return true; + } + private subscribeToProperties(): void { this.send({ command: ["observe_property", 1, "sub-text"] }); this.send({ command: ["observe_property", 2, "path"] }); @@ -873,11 +596,21 @@ export class MpvIpcClient implements MpvClient { } restorePreviousSecondarySubVisibility(): void { - const previous = this.deps.getPreviousSecondarySubVisibility(); + const previous = this.previousSecondarySubVisibility; if (previous === null) return; this.send({ command: ["set_property", "secondary-sub-visibility", previous ? "yes" : "no"], }); - this.deps.setPreviousSecondarySubVisibility(null); + this.previousSecondarySubVisibility = null; + } + + private setSecondarySubVisibility(visible: boolean): void { + this.send({ + command: [ + "set_property", + "secondary-sub-visibility", + visible ? "yes" : "no", + ], + }); } } diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts new file mode 100644 index 0000000..19a18cb --- /dev/null +++ b/src/main/app-lifecycle.ts @@ -0,0 +1,89 @@ +import type { CliArgs, CliCommandSource } from "../cli/args"; +import { runAppReadyRuntimeService } from "../core/services/startup-service"; +import type { AppReadyRuntimeDeps } from "../core/services/startup-service"; +import type { AppLifecycleDepsRuntimeOptions } from "../core/services/app-lifecycle-service"; + +export interface AppLifecycleRuntimeDepsFactoryInput { + app: AppLifecycleDepsRuntimeOptions["app"]; + platform: NodeJS.Platform; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; +} + +export interface AppReadyRuntimeDepsFactoryInput { + loadSubtitlePosition: AppReadyRuntimeDeps["loadSubtitlePosition"]; + resolveKeybindings: AppReadyRuntimeDeps["resolveKeybindings"]; + createMpvClient: AppReadyRuntimeDeps["createMpvClient"]; + reloadConfig: AppReadyRuntimeDeps["reloadConfig"]; + getResolvedConfig: AppReadyRuntimeDeps["getResolvedConfig"]; + getConfigWarnings: AppReadyRuntimeDeps["getConfigWarnings"]; + logConfigWarning: AppReadyRuntimeDeps["logConfigWarning"]; + initRuntimeOptionsManager: AppReadyRuntimeDeps["initRuntimeOptionsManager"]; + setSecondarySubMode: AppReadyRuntimeDeps["setSecondarySubMode"]; + defaultSecondarySubMode: AppReadyRuntimeDeps["defaultSecondarySubMode"]; + defaultWebsocketPort: AppReadyRuntimeDeps["defaultWebsocketPort"]; + hasMpvWebsocketPlugin: AppReadyRuntimeDeps["hasMpvWebsocketPlugin"]; + startSubtitleWebsocket: AppReadyRuntimeDeps["startSubtitleWebsocket"]; + log: AppReadyRuntimeDeps["log"]; + createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"]; + createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"]; + loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"]; + texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"]; + shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"]; + initializeOverlayRuntime: AppReadyRuntimeDeps["initializeOverlayRuntime"]; + handleInitialArgs: AppReadyRuntimeDeps["handleInitialArgs"]; +} + +export function createAppLifecycleRuntimeDeps( + params: AppLifecycleRuntimeDepsFactoryInput, +): AppLifecycleDepsRuntimeOptions { + return { + app: params.app, + platform: params.platform, + shouldStartApp: params.shouldStartApp, + parseArgs: params.parseArgs, + handleCliCommand: params.handleCliCommand, + printHelp: params.printHelp, + logNoRunningInstance: params.logNoRunningInstance, + onReady: params.onReady, + onWillQuitCleanup: params.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: params.restoreWindowsOnActivate, + }; +} + +export function createAppReadyRuntimeDeps( + params: AppReadyRuntimeDepsFactoryInput, +): AppReadyRuntimeDeps { + return { + loadSubtitlePosition: params.loadSubtitlePosition, + resolveKeybindings: params.resolveKeybindings, + createMpvClient: params.createMpvClient, + reloadConfig: params.reloadConfig, + getResolvedConfig: params.getResolvedConfig, + getConfigWarnings: params.getConfigWarnings, + logConfigWarning: params.logConfigWarning, + initRuntimeOptionsManager: params.initRuntimeOptionsManager, + setSecondarySubMode: params.setSecondarySubMode, + defaultSecondarySubMode: params.defaultSecondarySubMode, + defaultWebsocketPort: params.defaultWebsocketPort, + hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin, + startSubtitleWebsocket: params.startSubtitleWebsocket, + log: params.log, + createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, + createSubtitleTimingTracker: params.createSubtitleTimingTracker, + loadYomitanExtension: params.loadYomitanExtension, + texthookerOnlyMode: params.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: + params.shouldAutoInitializeOverlayRuntimeFromConfig, + initializeOverlayRuntime: params.initializeOverlayRuntime, + handleInitialArgs: params.handleInitialArgs, + }; +} diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 6981b9c..c092110 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -5,6 +5,10 @@ import { } from "../types"; import { SubsyncResolvedConfig } from "../subsync/utils"; import type { SubsyncRuntimeDeps } from "../core/services/subsync-runner-service"; +import type { IpcDepsRuntimeOptions } from "../core/services/ipc-service"; +import type { AnkiJimakuIpcRuntimeOptions } from "../core/services/anki-jimaku-service"; +import type { CliCommandDepsRuntimeOptions } from "../core/services/cli-command-service"; +import type { HandleMpvCommandFromIpcOptions } from "../core/services/ipc-command-service"; import { cycleRuntimeOptionFromIpcRuntimeService, setRuntimeOptionFromIpcRuntimeService, @@ -57,3 +61,251 @@ export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): Subs openManualPicker: params.openManualPicker, }; } + +export interface MainIpcRuntimeServiceDepsParams { + getInvisibleWindow: IpcDepsRuntimeOptions["getInvisibleWindow"]; + getMainWindow: IpcDepsRuntimeOptions["getMainWindow"]; + getVisibleOverlayVisibility: IpcDepsRuntimeOptions["getVisibleOverlayVisibility"]; + getInvisibleOverlayVisibility: IpcDepsRuntimeOptions["getInvisibleOverlayVisibility"]; + onOverlayModalClosed: IpcDepsRuntimeOptions["onOverlayModalClosed"]; + openYomitanSettings: IpcDepsRuntimeOptions["openYomitanSettings"]; + quitApp: IpcDepsRuntimeOptions["quitApp"]; + toggleVisibleOverlay: IpcDepsRuntimeOptions["toggleVisibleOverlay"]; + tokenizeCurrentSubtitle: IpcDepsRuntimeOptions["tokenizeCurrentSubtitle"]; + getCurrentSubtitleAss: IpcDepsRuntimeOptions["getCurrentSubtitleAss"]; + getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions["getMpvSubtitleRenderMetrics"]; + getSubtitlePosition: IpcDepsRuntimeOptions["getSubtitlePosition"]; + getSubtitleStyle: IpcDepsRuntimeOptions["getSubtitleStyle"]; + saveSubtitlePosition: IpcDepsRuntimeOptions["saveSubtitlePosition"]; + getMecabTokenizer: IpcDepsRuntimeOptions["getMecabTokenizer"]; + handleMpvCommand: IpcDepsRuntimeOptions["handleMpvCommand"]; + getKeybindings: IpcDepsRuntimeOptions["getKeybindings"]; + getSecondarySubMode: IpcDepsRuntimeOptions["getSecondarySubMode"]; + getMpvClient: IpcDepsRuntimeOptions["getMpvClient"]; + runSubsyncManual: IpcDepsRuntimeOptions["runSubsyncManual"]; + getAnkiConnectStatus: IpcDepsRuntimeOptions["getAnkiConnectStatus"]; + getRuntimeOptions: IpcDepsRuntimeOptions["getRuntimeOptions"]; + setRuntimeOption: IpcDepsRuntimeOptions["setRuntimeOption"]; + cycleRuntimeOption: IpcDepsRuntimeOptions["cycleRuntimeOption"]; + reportOverlayContentBounds: IpcDepsRuntimeOptions["reportOverlayContentBounds"]; +} + +export interface AnkiJimakuIpcRuntimeServiceDepsParams { + patchAnkiConnectEnabled: AnkiJimakuIpcRuntimeOptions["patchAnkiConnectEnabled"]; + getResolvedConfig: AnkiJimakuIpcRuntimeOptions["getResolvedConfig"]; + getRuntimeOptionsManager: AnkiJimakuIpcRuntimeOptions["getRuntimeOptionsManager"]; + getSubtitleTimingTracker: AnkiJimakuIpcRuntimeOptions["getSubtitleTimingTracker"]; + getMpvClient: AnkiJimakuIpcRuntimeOptions["getMpvClient"]; + getAnkiIntegration: AnkiJimakuIpcRuntimeOptions["getAnkiIntegration"]; + setAnkiIntegration: AnkiJimakuIpcRuntimeOptions["setAnkiIntegration"]; + showDesktopNotification: AnkiJimakuIpcRuntimeOptions["showDesktopNotification"]; + createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions["createFieldGroupingCallback"]; + broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions["broadcastRuntimeOptionsChanged"]; + getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions["getFieldGroupingResolver"]; + setFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions["setFieldGroupingResolver"]; + parseMediaInfo: AnkiJimakuIpcRuntimeOptions["parseMediaInfo"]; + getCurrentMediaPath: AnkiJimakuIpcRuntimeOptions["getCurrentMediaPath"]; + jimakuFetchJson: AnkiJimakuIpcRuntimeOptions["jimakuFetchJson"]; + getJimakuMaxEntryResults: AnkiJimakuIpcRuntimeOptions["getJimakuMaxEntryResults"]; + getJimakuLanguagePreference: AnkiJimakuIpcRuntimeOptions["getJimakuLanguagePreference"]; + resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions["resolveJimakuApiKey"]; + isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions["isRemoteMediaPath"]; + downloadToFile: AnkiJimakuIpcRuntimeOptions["downloadToFile"]; +} + +export interface CliCommandRuntimeServiceDepsParams { + mpv: { + getSocketPath: CliCommandDepsRuntimeOptions["mpv"]["getSocketPath"]; + setSocketPath: CliCommandDepsRuntimeOptions["mpv"]["setSocketPath"]; + getClient: CliCommandDepsRuntimeOptions["mpv"]["getClient"]; + showOsd: CliCommandDepsRuntimeOptions["mpv"]["showOsd"]; + }; + texthooker: { + service: CliCommandDepsRuntimeOptions["texthooker"]["service"]; + getPort: CliCommandDepsRuntimeOptions["texthooker"]["getPort"]; + setPort: CliCommandDepsRuntimeOptions["texthooker"]["setPort"]; + shouldOpenBrowser: CliCommandDepsRuntimeOptions["texthooker"]["shouldOpenBrowser"]; + openInBrowser: CliCommandDepsRuntimeOptions["texthooker"]["openInBrowser"]; + }; + overlay: { + isInitialized: CliCommandDepsRuntimeOptions["overlay"]["isInitialized"]; + initialize: CliCommandDepsRuntimeOptions["overlay"]["initialize"]; + toggleVisible: CliCommandDepsRuntimeOptions["overlay"]["toggleVisible"]; + toggleInvisible: CliCommandDepsRuntimeOptions["overlay"]["toggleInvisible"]; + setVisible: CliCommandDepsRuntimeOptions["overlay"]["setVisible"]; + setInvisible: CliCommandDepsRuntimeOptions["overlay"]["setInvisible"]; + }; + mining: { + copyCurrentSubtitle: CliCommandDepsRuntimeOptions["mining"]["copyCurrentSubtitle"]; + startPendingMultiCopy: + CliCommandDepsRuntimeOptions["mining"]["startPendingMultiCopy"]; + mineSentenceCard: CliCommandDepsRuntimeOptions["mining"]["mineSentenceCard"]; + startPendingMineSentenceMultiple: + CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"]; + updateLastCardFromClipboard: + CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"]; + triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"]; + triggerSubsyncFromConfig: + CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"]; + markLastCardAsAudioCard: + CliCommandDepsRuntimeOptions["mining"]["markLastCardAsAudioCard"]; + }; + ui: { + openYomitanSettings: CliCommandDepsRuntimeOptions["ui"]["openYomitanSettings"]; + cycleSecondarySubMode: CliCommandDepsRuntimeOptions["ui"]["cycleSecondarySubMode"]; + openRuntimeOptionsPalette: + CliCommandDepsRuntimeOptions["ui"]["openRuntimeOptionsPalette"]; + printHelp: CliCommandDepsRuntimeOptions["ui"]["printHelp"]; + }; + app: { + stop: CliCommandDepsRuntimeOptions["app"]["stop"]; + hasMainWindow: CliCommandDepsRuntimeOptions["app"]["hasMainWindow"]; + }; + getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions["getMultiCopyTimeoutMs"]; + schedule: CliCommandDepsRuntimeOptions["schedule"]; + log: CliCommandDepsRuntimeOptions["log"]; + warn: CliCommandDepsRuntimeOptions["warn"]; + error: CliCommandDepsRuntimeOptions["error"]; +} + +export interface MpvCommandRuntimeServiceDepsParams { + specialCommands: HandleMpvCommandFromIpcOptions["specialCommands"]; + runtimeOptionsCycle: HandleMpvCommandFromIpcOptions["runtimeOptionsCycle"]; + triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions["triggerSubsyncFromConfig"]; + openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions["openRuntimeOptionsPalette"]; + showMpvOsd: HandleMpvCommandFromIpcOptions["showMpvOsd"]; + mpvReplaySubtitle: HandleMpvCommandFromIpcOptions["mpvReplaySubtitle"]; + mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions["mpvPlayNextSubtitle"]; + mpvSendCommand: HandleMpvCommandFromIpcOptions["mpvSendCommand"]; + isMpvConnected: HandleMpvCommandFromIpcOptions["isMpvConnected"]; + hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions["hasRuntimeOptionsManager"]; +} + +export function createMainIpcRuntimeServiceDeps( + params: MainIpcRuntimeServiceDepsParams, +): IpcDepsRuntimeOptions { + return { + getInvisibleWindow: params.getInvisibleWindow, + getMainWindow: params.getMainWindow, + getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, + getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility, + onOverlayModalClosed: params.onOverlayModalClosed, + openYomitanSettings: params.openYomitanSettings, + quitApp: params.quitApp, + toggleVisibleOverlay: params.toggleVisibleOverlay, + tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, + getCurrentSubtitleAss: params.getCurrentSubtitleAss, + getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics, + getSubtitlePosition: params.getSubtitlePosition, + getSubtitleStyle: params.getSubtitleStyle, + saveSubtitlePosition: params.saveSubtitlePosition, + getMecabTokenizer: params.getMecabTokenizer, + handleMpvCommand: params.handleMpvCommand, + getKeybindings: params.getKeybindings, + getSecondarySubMode: params.getSecondarySubMode, + getMpvClient: params.getMpvClient, + runSubsyncManual: params.runSubsyncManual, + getAnkiConnectStatus: params.getAnkiConnectStatus, + getRuntimeOptions: params.getRuntimeOptions, + setRuntimeOption: params.setRuntimeOption, + cycleRuntimeOption: params.cycleRuntimeOption, + reportOverlayContentBounds: params.reportOverlayContentBounds, + }; +} + +export function createAnkiJimakuIpcRuntimeServiceDeps( + params: AnkiJimakuIpcRuntimeServiceDepsParams, +): AnkiJimakuIpcRuntimeOptions { + return { + patchAnkiConnectEnabled: params.patchAnkiConnectEnabled, + getResolvedConfig: params.getResolvedConfig, + getRuntimeOptionsManager: params.getRuntimeOptionsManager, + getSubtitleTimingTracker: params.getSubtitleTimingTracker, + getMpvClient: params.getMpvClient, + getAnkiIntegration: params.getAnkiIntegration, + setAnkiIntegration: params.setAnkiIntegration, + showDesktopNotification: params.showDesktopNotification, + createFieldGroupingCallback: params.createFieldGroupingCallback, + broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, + getFieldGroupingResolver: params.getFieldGroupingResolver, + setFieldGroupingResolver: params.setFieldGroupingResolver, + parseMediaInfo: params.parseMediaInfo, + getCurrentMediaPath: params.getCurrentMediaPath, + jimakuFetchJson: params.jimakuFetchJson, + getJimakuMaxEntryResults: params.getJimakuMaxEntryResults, + getJimakuLanguagePreference: params.getJimakuLanguagePreference, + resolveJimakuApiKey: params.resolveJimakuApiKey, + isRemoteMediaPath: params.isRemoteMediaPath, + downloadToFile: params.downloadToFile, + }; +} + +export function createCliCommandRuntimeServiceDeps( + params: CliCommandRuntimeServiceDepsParams, +): CliCommandDepsRuntimeOptions { + return { + mpv: { + getSocketPath: params.mpv.getSocketPath, + setSocketPath: params.mpv.setSocketPath, + getClient: params.mpv.getClient, + showOsd: params.mpv.showOsd, + }, + texthooker: { + service: params.texthooker.service, + getPort: params.texthooker.getPort, + setPort: params.texthooker.setPort, + shouldOpenBrowser: params.texthooker.shouldOpenBrowser, + openInBrowser: params.texthooker.openInBrowser, + }, + overlay: { + isInitialized: params.overlay.isInitialized, + initialize: params.overlay.initialize, + toggleVisible: params.overlay.toggleVisible, + toggleInvisible: params.overlay.toggleInvisible, + setVisible: params.overlay.setVisible, + setInvisible: params.overlay.setInvisible, + }, + mining: { + copyCurrentSubtitle: params.mining.copyCurrentSubtitle, + startPendingMultiCopy: params.mining.startPendingMultiCopy, + mineSentenceCard: params.mining.mineSentenceCard, + startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard, + triggerFieldGrouping: params.mining.triggerFieldGrouping, + triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig, + markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard, + }, + ui: { + openYomitanSettings: params.ui.openYomitanSettings, + cycleSecondarySubMode: params.ui.cycleSecondarySubMode, + openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, + printHelp: params.ui.printHelp, + }, + app: { + stop: params.app.stop, + hasMainWindow: params.app.hasMainWindow, + }, + getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs, + schedule: params.schedule, + log: params.log, + warn: params.warn, + error: params.error, + }; +} + +export function createMpvCommandRuntimeServiceDeps( + params: MpvCommandRuntimeServiceDepsParams, +): HandleMpvCommandFromIpcOptions { + return { + specialCommands: params.specialCommands, + triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, + openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, + runtimeOptionsCycle: params.runtimeOptionsCycle, + showMpvOsd: params.showMpvOsd, + mpvReplaySubtitle: params.mpvReplaySubtitle, + mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, + mpvSendCommand: params.mpvSendCommand, + isMpvConnected: params.isMpvConnected, + hasRuntimeOptionsManager: params.hasRuntimeOptionsManager, + }; +} diff --git a/src/main/startup.ts b/src/main/startup.ts new file mode 100644 index 0000000..ded88d7 --- /dev/null +++ b/src/main/startup.ts @@ -0,0 +1,63 @@ +import { CliArgs } from "../cli/args"; +import type { ResolvedConfig } from "../types"; +import type { StartupBootstrapRuntimeDeps } from "../core/services/startup-service"; + +export interface StartupBootstrapRuntimeFactoryDeps { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevelEnv: (level: string) => void; + enableVerboseLogging: () => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + shouldStartApp: (args: CliArgs) => boolean; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + configDir: string; + defaultConfig: ResolvedConfig; + generateConfigTemplate: (config: ResolvedConfig) => string; + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => Promise; + onConfigGenerated: (exitCode: number) => void; + onGenerateConfigError: (error: Error) => void; + startAppLifecycle: (args: CliArgs) => void; +} + +export function createStartupBootstrapRuntimeDeps( + params: StartupBootstrapRuntimeFactoryDeps, +): StartupBootstrapRuntimeDeps { + return { + argv: params.argv, + parseArgs: params.parseArgs, + setLogLevelEnv: params.setLogLevelEnv, + enableVerboseLogging: params.enableVerboseLogging, + forceX11Backend: (args: CliArgs) => params.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args: CliArgs) => + params.enforceUnsupportedWaylandMode(args), + getDefaultSocketPath: params.getDefaultSocketPath, + defaultTexthookerPort: params.defaultTexthookerPort, + runGenerateConfigFlow: (args: CliArgs) => { + if (!args.generateConfig || params.shouldStartApp(args)) { + return false; + } + params + .generateDefaultConfigFile(args, { + configDir: params.configDir, + defaultConfig: params.defaultConfig, + generateTemplate: (config: unknown) => + params.generateConfigTemplate(config as ResolvedConfig), + }) + .then((exitCode) => { + params.onConfigGenerated(exitCode); + }) + .catch(params.onGenerateConfigError); + return true; + }, + startAppLifecycle: params.startAppLifecycle, + }; +} diff --git a/subminer b/subminer index 02b6c2e..603d189 100755 --- a/subminer +++ b/subminer @@ -722,6 +722,13 @@ function resolvePathMaybe(input: string): string { return input; } +function resolveBinaryPathCandidate(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + const unquoted = trimmed.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); + return resolvePathMaybe(unquoted); +} + function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { const configDir = path.join(os.homedir(), ".config", "SubMiner"); const jsoncPath = path.join(configDir, "config.jsonc"); @@ -1653,8 +1660,17 @@ function formatPickerLaunchError( } function findAppBinary(selfPath: string): string | null { - const envPath = process.env.SUBMINER_APPIMAGE_PATH; - if (envPath && isExecutable(envPath)) return envPath; + const envPaths = [ + process.env.SUBMINER_APPIMAGE_PATH, + process.env.SUBMINER_BINARY_PATH, + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const envPath of envPaths) { + const resolved = resolveBinaryPathCandidate(envPath); + if (resolved && isExecutable(resolved)) { + return resolved; + } + } const candidates: string[] = []; if (process.platform === "darwin") {