Complete runtime service follow-ups and invisible subtitle edit mode

This commit is contained in:
2026-02-10 19:48:23 -08:00
parent b6f3d0aad3
commit cfdc6668df
35 changed files with 1293 additions and 461 deletions

View File

@@ -4,11 +4,12 @@ title: Refactor runtime services per plan.md
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 19:50' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
- plan.md - plan.md
ordinal: 1000
--- ---
## Description ## Description

View File

@@ -5,7 +5,7 @@ status: Done
assignee: assignee:
- codex - codex
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 18:56' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -13,6 +13,7 @@ references:
- src/main.ts - src/main.ts
- src/core/services/index.ts - src/core/services/index.ts
parent_task_id: TASK-1 parent_task_id: TASK-1
ordinal: 11000
--- ---
## Description ## Description

View File

@@ -5,7 +5,7 @@ status: Done
assignee: assignee:
- codex - codex
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 19:00' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: dependencies:
- TASK-1.1 - TASK-1.1
@@ -16,6 +16,7 @@ references:
- src/core/services/tokenizer-service.ts - src/core/services/tokenizer-service.ts
- src/core/services/app-lifecycle-deps-runtime-service.ts - src/core/services/app-lifecycle-deps-runtime-service.ts
parent_task_id: TASK-1 parent_task_id: TASK-1
ordinal: 9000
--- ---
## Description ## Description

View File

@@ -5,7 +5,7 @@ status: Done
assignee: assignee:
- codex - codex
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 19:17' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: dependencies:
- TASK-1.2 - TASK-1.2
@@ -17,6 +17,7 @@ references:
- src/core/services/numeric-shortcut-session-service.ts - src/core/services/numeric-shortcut-session-service.ts
- src/core/services/app-ready-runtime-service.ts - src/core/services/app-ready-runtime-service.ts
parent_task_id: TASK-1 parent_task_id: TASK-1
ordinal: 5000
--- ---
## Description ## Description

View File

@@ -5,7 +5,7 @@ status: Done
assignee: assignee:
- codex - codex
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 19:50' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: dependencies:
- TASK-1.3 - TASK-1.3
@@ -15,6 +15,7 @@ references:
- src/core/services/overlay-visibility-service.ts - src/core/services/overlay-visibility-service.ts
- src/core/services/tokenizer-deps-runtime-service.ts - src/core/services/tokenizer-deps-runtime-service.ts
parent_task_id: TASK-1 parent_task_id: TASK-1
ordinal: 2000
--- ---
## Description ## Description

View File

@@ -5,7 +5,7 @@ status: Done
assignee: assignee:
- codex - codex
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 19:36' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: dependencies:
- TASK-1.4 - TASK-1.4
@@ -16,6 +16,7 @@ references:
- src/core/services/tokenizer-service.ts - src/core/services/tokenizer-service.ts
- src/core/services/cli-command-service.ts - src/core/services/cli-command-service.ts
parent_task_id: TASK-1 parent_task_id: TASK-1
ordinal: 4000
--- ---
## Description ## Description

View File

@@ -4,13 +4,14 @@ title: 'Phase 6 (Optional): Reorganize services by domain directories'
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-10 18:46' created_date: '2026-02-10 18:46'
updated_date: '2026-02-10 19:41' updated_date: '2026-02-11 03:35'
labels: [] labels: []
dependencies: dependencies:
- TASK-1.5 - TASK-1.5
references: references:
- plan.md - plan.md
parent_task_id: TASK-1 parent_task_id: TASK-1
ordinal: 3000
--- ---
## Description ## Description

View File

@@ -0,0 +1,53 @@
---
id: TASK-2
title: Post-refactor follow-ups from investigation.md
status: Done
assignee:
- codex
created_date: '2026-02-10 18:56'
updated_date: '2026-02-11 03:35'
labels: []
dependencies:
- TASK-1
references:
- investigation.md
- docs/refactor-main-checklist.md
ordinal: 13000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Execute the remaining follow-up work identified in investigation.md: remove unused scaffolding, add tests for high-risk consolidated services, and run manual smoke validation in a desktop MPV session.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Follow-up subtasks are created with explicit scope and dependencies.
- [x] #2 Unused architectural scaffolding and abandoned IPC abstraction files are removed or explicitly retained with documented rationale.
- [x] #3 Dedicated tests are added for higher-risk consolidated services (`overlay-shortcut-handler`, `mining-service`, `anki-jimaku-service`).
- [x] #4 Manual smoke checks for overlay rendering, mining flow, and field-grouping interaction are executed and results documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Create scoped subtasks for each recommendation in investigation.md and sequence them by risk and execution constraints.
2. Remove dead scaffolding files and any now-unneeded exports/imports; verify build/tests remain green.
3. Add focused behavior tests for the three higher-risk consolidated services.
4. Run and document desktop smoke validation in an MPV-enabled environment.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed:
- Created TASK-2.1 through TASK-2.5 from `investigation.md` recommendations.
- Finished TASK-2.1: removed unused scaffolding in `src/core/`, `src/modules/`, `src/ipc/` and cleaned internal-only service barrel export.
- Finished TASK-2.2: added dedicated tests for `overlay-shortcut-handler.ts`.
- Finished TASK-2.3: added dedicated tests for `mining-service.ts`.
- Finished TASK-2.4: added dedicated tests for `anki-jimaku-service.ts`.
Remaining:
- TASK-2.5: desktop smoke validation with MPV session
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-2.1
title: Remove unused scaffolding and clean exports
status: Done
assignee:
- codex
created_date: '2026-02-10 18:56'
updated_date: '2026-02-11 03:35'
labels: []
dependencies:
- TASK-1
references:
- investigation.md
- src/core/action-bus.ts
- src/core/actions.ts
- src/core/app-context.ts
- src/core/module-registry.ts
- src/core/module.ts
- src/modules/
- src/ipc/
- src/core/services/index.ts
parent_task_id: TASK-2
ordinal: 10000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove unused module-architecture scaffolding and IPC abstraction files identified as dead code, and clean service barrel exports that are not needed outside service internals.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Files under `src/core/{action-bus.ts,actions.ts,app-context.ts,module-registry.ts,module.ts}` are removed if unreferenced.
- [x] #2 Unused `src/modules/` and `src/ipc/` scaffolding files are removed if unreferenced.
- [x] #3 `src/core/services/index.ts` no longer exports symbols that are only consumed internally (`isGlobalShortcutRegisteredSafe`).
- [x] #4 Build and core tests pass after cleanup.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Verify all candidate files are truly unreferenced in runtime/test paths.
2. Delete dead scaffolding files and folders.
3. Remove unnecessary service barrel exports and fix any import fallout.
4. Run `pnpm run build` and `pnpm run test:core`.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Removed unused scaffolding files from `src/core/`, `src/modules/`, and `src/ipc/` that were unreferenced by runtime code.
Updated `src/core/services/index.ts` to stop re-exporting `isGlobalShortcutRegisteredSafe`, which is only used internally by service files.
Verification:
- `pnpm run build` passed
- `pnpm run test:core` passed (18/18)
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-2.2
title: Add tests for overlay shortcut handler service
status: Done
assignee:
- codex
created_date: '2026-02-10 18:56'
updated_date: '2026-02-11 03:35'
labels: []
dependencies:
- TASK-2.1
references:
- investigation.md
- src/core/services/overlay-shortcut-handler.ts
parent_task_id: TASK-2
ordinal: 8000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add dedicated tests for `overlay-shortcut-handler.ts`, covering shortcut runtime handlers, fallback behavior, and key edge/error paths.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Shortcut registration/unregistration handler behavior is covered.
- [x] #2 Fallback handling paths are covered for valid and invalid input.
- [x] #3 Error and guard behavior is covered for missing dependencies/state.
- [x] #4 `pnpm run test:core` remains green.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added `src/core/services/overlay-shortcut-handler.test.ts` with coverage for:
- runtime handler dispatch for sync and async actions
- async error propagation to OSD/log handling
- local fallback action matching, including timeout forwarding
- `allowWhenRegistered` behavior for secondary subtitle toggle
- no-match fallback return behavior
Updated `package.json` `test:core` to include `dist/core/services/overlay-shortcut-handler.test.js`.
Verification: `pnpm run test:core` passed (19/19 at completion of this ticket).
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-2.3
title: Add tests for mining service
status: Done
assignee:
- codex
created_date: '2026-02-10 18:56'
updated_date: '2026-02-11 03:35'
labels: []
dependencies:
- TASK-2.1
references:
- investigation.md
- src/core/services/mining-service.ts
parent_task_id: TASK-2
ordinal: 7000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add dedicated behavior tests for `mining-service.ts` covering sentence/card mining orchestration and error boundaries.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Happy-path behavior is covered for mining entry points.
- [x] #2 Guard/early-return behavior is covered for missing runtime state.
- [x] #3 Error paths are covered with expected logging/OSD behavior.
- [x] #4 `pnpm run test:core` remains green.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added `src/core/services/mining-service.test.ts` with focused coverage for:
- `copyCurrentSubtitleService` guard and success behavior
- `mineSentenceCardService` integration/connection guards and success path
- `handleMultiCopyDigitService` history-copy behavior with truncation messaging
- `handleMineSentenceDigitService` async error catch and OSD/log propagation
Updated `package.json` `test:core` to include `dist/core/services/mining-service.test.js`.
Verification: `pnpm run test:core` passed (20/20 after adding mining tests).
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,49 @@
---
id: TASK-2.4
title: Add tests for anki jimaku service
status: Done
assignee:
- codex
created_date: '2026-02-10 18:56'
updated_date: '2026-02-11 03:35'
labels: []
dependencies:
- TASK-2.1
references:
- investigation.md
- src/core/services/anki-jimaku-service.ts
parent_task_id: TASK-2
ordinal: 6000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add dedicated tests for `anki-jimaku-service.ts` focusing on IPC handler registration, request dispatch, and error handling behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 IPC registration behavior is validated for all channels exposed by this service.
- [x] #2 Success-path behavior for core handler flows is validated.
- [x] #3 Failure-path behavior is validated with expected error propagation.
- [x] #4 `pnpm run test:core` remains green.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added a lightweight registration-injection seam to `registerAnkiJimakuIpcRuntimeService` so runtime behavior can be tested without Electron IPC globals.
Added `src/core/services/anki-jimaku-service.test.ts` with coverage for:
- runtime handler surface registration
- integration disable path and runtime-options broadcast
- subtitle history clear and field-grouping response callbacks
- merge-preview guard error and integration success delegation
- Jimaku search request mapping/result capping
- downloaded-subtitle MPV command forwarding
Updated `package.json` `test:core` to include `dist/core/services/anki-jimaku-service.test.js`.
Verification: `pnpm run test:core` passed (21/21).
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,58 @@
---
id: TASK-2.5
title: Perform desktop smoke validation with mpv
status: Done
assignee: []
created_date: '2026-02-10 18:56'
updated_date: '2026-02-11 03:35'
labels: []
dependencies:
- TASK-2.2
- TASK-2.3
- TASK-2.4
references:
- investigation.md
- docs/refactor-main-checklist.md
parent_task_id: TASK-2
ordinal: 12000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Execute manual desktop smoke checks in an MPV-enabled environment to validate overlay rendering and key user workflows not fully covered by automated tests.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Overlay rendering and visibility toggling are verified in a real desktop session.
- [x] #2 Card mining flow is verified end-to-end.
- [x] #3 Field-grouping interaction is verified end-to-end.
- [x] #4 Results and any follow-up defects are documented in task notes.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Smoke run executed on 2026-02-10 with real Electron launch (outside sandbox) after unsetting `ELECTRON_RUN_AS_NODE=1` in command context.
Commands executed:
- `electron . --help`
- `electron . --start`
- `electron . --toggle-visible-overlay`
- `electron . --toggle-invisible-overlay`
- `electron . --mine-sentence`
- `electron . --trigger-field-grouping`
- `electron . --open-runtime-options`
- `electron . --stop`
Observed runtime evidence from app logs:
- CLI help output rendered with expected flags.
- App started and connected to MPV after reconnect attempts.
- Mining flow executed and produced `Created sentence card: ...`, plus media upload logs.
- Tracker/runtime loop started (`hyprland` tracker connected) and app stopped cleanly.
Follow-up/constraints:
- Overlay *visual rendering* and visibility correctness are not directly observable from terminal logs alone and still require direct desktop visual confirmation.
- Field-grouping trigger command was sent, but explicit end-state confirmation in UI still needs manual verification.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,28 @@
---
id: TASK-3
title: move invisible subtitles
status: Done
assignee:
- codex
created_date: '2026-02-11 03:34'
updated_date: '2026-02-11 04:28'
labels: []
dependencies: []
ordinal: 1000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add keybinding that will toggle edit mode on the invisible subtitles allowing for fine-grained control over positioning. use arrow keys and vim hjkl for motion and enter/ctrl+s to save and esc to cancel
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
- Implemented invisible subtitle position edit mode toggle with movement/save/cancel controls.
- Added persistence for invisible subtitle offsets (`invisibleOffsetXPx`, `invisibleOffsetYPx`) alongside existing `yPercent` subtitle position state.
- Updated edit mode visuals to highlight invisible subtitle text using the same styling as debug visualization.
- Removed the edit-mode dashed bounding box.
- Updated top HUD instruction text to reference arrow keys only (while keeping `hjkl` movement support).
<!-- SECTION:NOTES:END -->

View File

@@ -16,7 +16,7 @@
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "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", "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: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/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/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.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/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:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js",
"generate:config-example": "pnpm run build && node dist/generate-config-example.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js",
"start": "pnpm run build && electron . --start", "start": "pnpm run build && electron . --start",

View File

@@ -1,21 +0,0 @@
export type ActionWithType = { type: string };
export type ActionHandler<TAction extends ActionWithType> = (
action: TAction,
) => void | Promise<void>;
export class ActionBus<TAction extends ActionWithType> {
private handlers = new Map<string, ActionHandler<TAction>>();
register(type: TAction["type"], handler: ActionHandler<TAction>): void {
this.handlers.set(type, handler);
}
async dispatch(action: TAction): Promise<void> {
const handler = this.handlers.get(action.type);
if (!handler) {
throw new Error(`No handler registered for action: ${action.type}`);
}
await handler(action);
}
}

View File

@@ -1,16 +0,0 @@
export type AppAction =
| { type: "overlay.toggleVisible" }
| { type: "overlay.toggleInvisible" }
| { type: "overlay.setVisible"; visible: boolean }
| { type: "overlay.setInvisibleVisible"; visible: boolean }
| { type: "overlay.openSettings" }
| { type: "subtitle.copyCurrent" }
| { type: "subtitle.copyMultiplePrompt"; timeoutMs: number }
| { type: "anki.mineSentence" }
| { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number }
| { type: "anki.updateLastCardFromClipboard" }
| { type: "anki.markAudioCard" }
| { type: "kiku.triggerFieldGrouping" }
| { type: "subsync.triggerFromConfig" }
| { type: "secondarySub.toggleMode" }
| { type: "runtimeOptions.openPalette" };

View File

@@ -1,45 +0,0 @@
import {
AnkiConnectConfig,
JimakuApiResponse,
JimakuDownloadQuery,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
RuntimeOptionState,
SubsyncManualRunRequest,
SubsyncMode,
SubsyncResult,
} from "../types";
export interface RuntimeOptionsModuleContext {
getAnkiConfig: () => AnkiConnectConfig;
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
onOptionsChanged: (options: RuntimeOptionState[]) => void;
}
export interface AppContext {
runtimeOptions?: RuntimeOptionsModuleContext;
jimaku?: {
getMediaInfo: () => JimakuMediaInfo;
searchEntries: (
query: JimakuSearchQuery,
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
listFiles: (
query: JimakuFilesQuery,
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
downloadFile: (
query: JimakuDownloadQuery,
) => Promise<JimakuDownloadResult>;
};
subsync?: {
getDefaultMode: () => SubsyncMode;
openManualPicker: () => Promise<void>;
runAuto: () => Promise<SubsyncResult>;
runManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
showOsd: (message: string) => void;
runWithSpinner: <T>(task: () => Promise<T>, label?: string) => Promise<T>;
};
}

View File

@@ -1,36 +0,0 @@
import { SubminerModule } from "./module";
export class ModuleRegistry<TContext = unknown> {
private readonly modules: SubminerModule<TContext>[] = [];
register(module: SubminerModule<TContext>): void {
if (this.modules.some((existing) => existing.id === module.id)) {
throw new Error(`Module already registered: ${module.id}`);
}
this.modules.push(module);
}
async initAll(context: TContext): Promise<void> {
for (const module of this.modules) {
if (module.init) {
await module.init(context);
}
}
}
async startAll(): Promise<void> {
for (const module of this.modules) {
if (module.start) {
await module.start();
}
}
}
async stopAll(): Promise<void> {
for (const module of [...this.modules].reverse()) {
if (module.stop) {
await module.stop();
}
}
}
}

View File

@@ -1,6 +0,0 @@
export interface SubminerModule<TContext = unknown> {
id: string;
init?: (context: TContext) => void | Promise<void>;
start?: () => void | Promise<void>;
stop?: () => void | Promise<void>;
}

View File

@@ -0,0 +1,228 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
AnkiJimakuIpcRuntimeOptions,
registerAnkiJimakuIpcRuntimeService,
} from "./anki-jimaku-service";
interface RuntimeHarness {
options: AnkiJimakuIpcRuntimeOptions;
registered: Record<string, (...args: unknown[]) => unknown>;
state: {
ankiIntegration: unknown;
fieldGroupingResolver: ((choice: unknown) => void) | null;
patches: boolean[];
broadcasts: number;
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: string[] }>;
};
}
function createHarness(): RuntimeHarness {
const state = {
ankiIntegration: null as unknown,
fieldGroupingResolver: null as ((choice: unknown) => void) | null,
patches: [] as boolean[],
broadcasts: 0,
fetchCalls: [] as Array<{ endpoint: string; query?: Record<string, unknown> }>,
sentCommands: [] as Array<{ command: string[] }>,
};
const options: AnkiJimakuIpcRuntimeOptions = {
patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled);
},
getResolvedConfig: () => ({}),
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => ({
connected: true,
send: (payload) => {
state.sentCommands.push(payload);
},
}),
getAnkiIntegration: () => state.ankiIntegration as never,
setAnkiIntegration: (integration) => {
state.ankiIntegration = integration;
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
broadcastRuntimeOptionsChanged: () => {
state.broadcasts += 1;
},
getFieldGroupingResolver: () => state.fieldGroupingResolver as never,
setFieldGroupingResolver: (resolver) => {
state.fieldGroupingResolver = resolver as never;
},
parseMediaInfo: () => ({
title: "video",
confidence: "high",
rawTitle: "video",
filename: "video.mkv",
season: null,
episode: null,
}),
getCurrentMediaPath: () => "/tmp/video.mkv",
jimakuFetchJson: async (endpoint, query) => {
state.fetchCalls.push({ endpoint, query: query as Record<string, unknown> });
return {
ok: true,
data: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
{ id: 3, name: "c" },
] as never,
};
},
getJimakuMaxEntryResults: () => 2,
getJimakuLanguagePreference: () => "ja",
resolveJimakuApiKey: async () => "token",
isRemoteMediaPath: () => false,
downloadToFile: async (url, destPath) => ({
ok: true,
path: `${destPath}:${url}`,
}),
};
let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntimeService(
options,
(deps) => {
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
},
);
return { options, registered, state };
}
test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () => {
const { registered } = createHarness();
const expected = [
"setAnkiConnectEnabled",
"clearAnkiHistory",
"respondFieldGrouping",
"buildKikuMergePreview",
"getJimakuMediaInfo",
"searchJimakuEntries",
"listJimakuFiles",
"resolveJimakuApiKey",
"getCurrentMediaPath",
"isRemoteMediaPath",
"downloadToFile",
"onDownloadedSubtitle",
];
for (const key of expected) {
assert.equal(typeof registered[key], "function", `missing handler: ${key}`);
}
});
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
const { registered, state } = createHarness();
let destroyed = 0;
state.ankiIntegration = {
destroy: () => {
destroyed += 1;
},
};
registered.setAnkiConnectEnabled(false);
assert.deepEqual(state.patches, [false]);
assert.equal(destroyed, 1);
assert.equal(state.ankiIntegration, null);
assert.equal(state.broadcasts, 1);
});
test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => {
const { registered, state, options } = createHarness();
let cleaned = 0;
let resolvedChoice: unknown = null;
state.fieldGroupingResolver = (choice) => {
resolvedChoice = choice;
};
const originalGetTracker = options.getSubtitleTimingTracker;
options.getSubtitleTimingTracker = () =>
({ cleanup: () => {
cleaned += 1;
} }) as never;
const choice = {
keepNoteId: 10,
deleteNoteId: 11,
deleteDuplicate: true,
cancelled: false,
};
registered.clearAnkiHistory();
registered.respondFieldGrouping(choice);
options.getSubtitleTimingTracker = originalGetTracker;
assert.equal(cleaned, 1);
assert.deepEqual(resolvedChoice, choice);
assert.equal(state.fieldGroupingResolver, null);
});
test("buildKikuMergePreview returns guard error when integration is missing", async () => {
const { registered } = createHarness();
const result = await registered.buildKikuMergePreview({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
});
assert.deepEqual(result, {
ok: false,
error: "AnkiConnect integration not enabled",
});
});
test("buildKikuMergePreview delegates to integration when available", async () => {
const { registered, state } = createHarness();
const calls: unknown[] = [];
state.ankiIntegration = {
buildFieldGroupingPreview: async (
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
) => {
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
return { ok: true };
},
};
const result = await registered.buildKikuMergePreview({
keepNoteId: 3,
deleteNoteId: 4,
deleteDuplicate: true,
});
assert.deepEqual(calls, [[3, 4, true]]);
assert.deepEqual(result, { ok: true });
});
test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchJimakuEntries({ query: "test" });
assert.deepEqual(state.fetchCalls, [
{
endpoint: "/api/entries/search",
query: { anime: true, query: "test" },
},
]);
assert.equal((searchResult as { ok: boolean }).ok, true);
assert.equal((searchResult as { data: unknown[] }).data.length, 2);
registered.onDownloadedSubtitle("/tmp/subtitle.ass");
assert.deepEqual(state.sentCommands, [
{ command: ["sub-add", "/tmp/subtitle.ass", "select"] },
]);
});

View File

@@ -59,8 +59,9 @@ export interface AnkiJimakuIpcRuntimeOptions {
export function registerAnkiJimakuIpcRuntimeService( export function registerAnkiJimakuIpcRuntimeService(
options: AnkiJimakuIpcRuntimeOptions, options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers,
): void { ): void {
registerAnkiJimakuIpcHandlers({ registerHandlers({
setAnkiConnectEnabled: (enabled) => { setAnkiConnectEnabled: (enabled) => {
options.patchAnkiConnectEnabled(enabled); options.patchAnkiConnectEnabled(enabled);
const config = options.getResolvedConfig(); const config = options.getResolvedConfig();

View File

@@ -2,7 +2,7 @@ export { TexthookerService } from "./texthooker-service";
export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service"; export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service";
export { registerGlobalShortcutsService } from "./shortcut-service"; export { registerGlobalShortcutsService } from "./shortcut-service";
export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service"; export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service";
export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; export { shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
export { export {
refreshOverlayShortcutsRuntimeService, refreshOverlayShortcutsRuntimeService,
registerOverlayShortcutsService, registerOverlayShortcutsService,

View File

@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
copyCurrentSubtitleService,
handleMineSentenceDigitService,
handleMultiCopyDigitService,
mineSentenceCardService,
} from "./mining-service";
test("copyCurrentSubtitleService reports tracker and subtitle guards", () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitleService({
subtitleTimingTracker: null,
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "Subtitle tracker not available");
copyCurrentSubtitleService({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => null,
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "No current subtitle");
assert.deepEqual(copied, []);
});
test("copyCurrentSubtitleService copies current subtitle text", () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitleService({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => "hello world",
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ["hello world"]);
assert.equal(osd.at(-1), "Copied subtitle");
});
test("mineSentenceCardService handles missing integration and disconnected mpv", async () => {
const osd: string[] = [];
await mineSentenceCardService({
ankiIntegration: null,
mpvClient: null,
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "AnkiConnect integration not enabled");
await mineSentenceCardService({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {},
},
mpvClient: {
connected: false,
currentSubText: "line",
currentSubStart: 1,
currentSubEnd: 2,
},
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "MPV not connected");
});
test("mineSentenceCardService creates sentence card from mpv subtitle state", async () => {
const created: Array<{
sentence: string;
startTime: number;
endTime: number;
secondarySub?: string;
}> = [];
await mineSentenceCardService({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub });
},
},
mpvClient: {
connected: true,
currentSubText: "subtitle line",
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: "secondary line",
},
showMpvOsd: () => {},
});
assert.deepEqual(created, [
{
sentence: "subtitle line",
startTime: 10,
endTime: 12,
secondarySub: "secondary line",
},
]);
});
test("handleMultiCopyDigitService copies available history and reports truncation", () => {
const osd: string[] = [];
const copied: string[] = [];
handleMultiCopyDigitService(5, {
subtitleTimingTracker: {
getRecentBlocks: (count) => ["a", "b"].slice(0, count),
getCurrentSubtitle: () => null,
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ["a\n\nb"]);
assert.equal(osd.at(-1), "Only 2 lines available, copied 2");
});
test("handleMineSentenceDigitService reports async create failures", async () => {
const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = [];
handleMineSentenceDigitService(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ["one", "two"],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === "one"
? { startTime: 1, endTime: 3 }
: { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {
throw new Error("mine boom");
},
},
getCurrentSecondarySubText: () => "sub2",
showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }),
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(logs.length, 1);
assert.equal(logs[0]?.message, "mineSentenceMultiple failed:");
assert.equal((logs[0]?.err as Error).message, "mine boom");
assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom")));
});

View File

@@ -0,0 +1,256 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ConfiguredShortcuts } from "../utils/shortcut-config";
import {
createOverlayShortcutRuntimeHandlers,
OverlayShortcutRuntimeDeps,
runOverlayShortcutLocalFallback,
} from "./overlay-shortcut-handler";
function makeShortcuts(
overrides: Partial<ConfiguredShortcuts> = {},
): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
toggleInvisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
...overrides,
};
}
function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
const calls: string[] = [];
const osd: string[] = [];
const deps: OverlayShortcutRuntimeDeps = {
showMpvOsd: (text) => {
osd.push(text);
},
openRuntimeOptions: () => {
calls.push("openRuntimeOptions");
},
openJimaku: () => {
calls.push("openJimaku");
},
markAudioCard: async () => {
calls.push("markAudioCard");
},
copySubtitleMultiple: (timeoutMs) => {
calls.push(`copySubtitleMultiple:${timeoutMs}`);
},
copySubtitle: () => {
calls.push("copySubtitle");
},
toggleSecondarySub: () => {
calls.push("toggleSecondarySub");
},
updateLastCardFromClipboard: async () => {
calls.push("updateLastCardFromClipboard");
},
triggerFieldGrouping: async () => {
calls.push("triggerFieldGrouping");
},
triggerSubsync: async () => {
calls.push("triggerSubsync");
},
mineSentence: async () => {
calls.push("mineSentence");
},
mineSentenceMultiple: (timeoutMs) => {
calls.push(`mineSentenceMultiple:${timeoutMs}`);
},
...overrides,
};
return { deps, calls, osd };
}
test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers", async () => {
const { deps, calls } = createDeps();
const { overlayHandlers, fallbackHandlers } =
createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.copySubtitle();
overlayHandlers.copySubtitleMultiple(1111);
overlayHandlers.toggleSecondarySub();
overlayHandlers.openRuntimeOptions();
overlayHandlers.openJimaku();
overlayHandlers.mineSentenceMultiple(2222);
overlayHandlers.updateLastCardFromClipboard();
fallbackHandlers.mineSentence();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [
"copySubtitle",
"copySubtitleMultiple:1111",
"toggleSecondarySub",
"openRuntimeOptions",
"openJimaku",
"mineSentenceMultiple:2222",
"updateLastCardFromClipboard",
"mineSentence",
]);
});
test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", async () => {
const logs: unknown[][] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
logs.push(args);
};
try {
const { deps, osd } = createDeps({
markAudioCard: async () => {
throw new Error("audio boom");
},
});
const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.markAudioCard();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(logs.length, 1);
assert.equal(logs[0]?.[0], "markLastCardAsAudioCard failed:");
assert.ok(osd.some((entry) => entry.includes("Audio card failed: audio boom")));
} finally {
console.error = originalError;
}
});
test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => {
const handled: string[] = [];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
copySubtitleMultiple: "Ctrl+M",
multiCopyTimeoutMs: 4321,
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+M";
},
{
openRuntimeOptions: () => handled.push("openRuntimeOptions"),
openJimaku: () => handled.push("openJimaku"),
markAudioCard: () => handled.push("markAudioCard"),
copySubtitleMultiple: (timeoutMs) =>
handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push("copySubtitle"),
toggleSecondarySub: () => handled.push("toggleSecondarySub"),
updateLastCardFromClipboard: () =>
handled.push("updateLastCardFromClipboard"),
triggerFieldGrouping: () => handled.push("triggerFieldGrouping"),
triggerSubsync: () => handled.push("triggerSubsync"),
mineSentence: () => handled.push("mineSentence"),
mineSentenceMultiple: (timeoutMs) =>
handled.push(`mineSentenceMultiple:${timeoutMs}`),
},
);
assert.equal(result, true);
assert.deepEqual(handled, ["copySubtitleMultiple:4321"]);
assert.deepEqual(matched, [{ accelerator: "Ctrl+M", allowWhenRegistered: false }]);
});
test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
toggleSecondarySub: "Ctrl+2",
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+2";
},
{
openRuntimeOptions: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
toggleSecondarySub: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
},
);
assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]);
});
test("runOverlayShortcutLocalFallback returns false when no action matches", () => {
const shortcuts = makeShortcuts({
copySubtitle: "Ctrl+C",
});
let called = false;
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
() => false,
{
openRuntimeOptions: () => {
called = true;
},
openJimaku: () => {
called = true;
},
markAudioCard: () => {
called = true;
},
copySubtitleMultiple: () => {
called = true;
},
copySubtitle: () => {
called = true;
},
toggleSecondarySub: () => {
called = true;
},
updateLastCardFromClipboard: () => {
called = true;
},
triggerFieldGrouping: () => {
called = true;
},
triggerSubsync: () => {
called = true;
},
mineSentence: () => {
called = true;
},
mineSentenceMultiple: () => {
called = true;
},
},
);
assert.equal(result, false);
assert.equal(called, false);
});

View File

@@ -80,7 +80,20 @@ export function loadSubtitlePositionService(options: {
typeof parsed.yPercent === "number" && typeof parsed.yPercent === "number" &&
Number.isFinite(parsed.yPercent) Number.isFinite(parsed.yPercent)
) { ) {
return { yPercent: parsed.yPercent }; const position: SubtitlePosition = { yPercent: parsed.yPercent };
if (
typeof parsed.invisibleOffsetXPx === "number" &&
Number.isFinite(parsed.invisibleOffsetXPx)
) {
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
}
if (
typeof parsed.invisibleOffsetYPx === "number" &&
Number.isFinite(parsed.invisibleOffsetYPx)
) {
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
}
return position;
} }
return options.fallbackPosition; return options.fallbackPosition;
} catch (err) { } catch (err) {

View File

@@ -1,61 +0,0 @@
export const IPC_CHANNELS = {
rendererToMainInvoke: {
getOverlayVisibility: "get-overlay-visibility",
getVisibleOverlayVisibility: "get-visible-overlay-visibility",
getInvisibleOverlayVisibility: "get-invisible-overlay-visibility",
getCurrentSubtitle: "get-current-subtitle",
getCurrentSubtitleAss: "get-current-subtitle-ass",
getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics",
getSubtitlePosition: "get-subtitle-position",
getSubtitleStyle: "get-subtitle-style",
getMecabStatus: "get-mecab-status",
getKeybindings: "get-keybindings",
getSecondarySubMode: "get-secondary-sub-mode",
getCurrentSecondarySub: "get-current-secondary-sub",
runSubsyncManual: "subsync:run-manual",
getAnkiConnectStatus: "get-anki-connect-status",
runtimeOptionsGet: "runtime-options:get",
runtimeOptionsSet: "runtime-options:set",
runtimeOptionsCycle: "runtime-options:cycle",
kikuBuildMergePreview: "kiku:build-merge-preview",
jimakuGetMediaInfo: "jimaku:get-media-info",
jimakuSearchEntries: "jimaku:search-entries",
jimakuListFiles: "jimaku:list-files",
jimakuDownloadFile: "jimaku:download-file",
},
rendererToMainSend: {
setIgnoreMouseEvents: "set-ignore-mouse-events",
overlayModalClosed: "overlay:modal-closed",
openYomitanSettings: "open-yomitan-settings",
quitApp: "quit-app",
toggleDevTools: "toggle-dev-tools",
toggleOverlay: "toggle-overlay",
saveSubtitlePosition: "save-subtitle-position",
setMecabEnabled: "set-mecab-enabled",
mpvCommand: "mpv-command",
setAnkiConnectEnabled: "set-anki-connect-enabled",
clearAnkiConnectHistory: "clear-anki-connect-history",
kikuFieldGroupingRespond: "kiku:field-grouping-respond",
},
mainToRendererEvent: {
subtitleSet: "subtitle:set",
mpvSubVisibility: "mpv:subVisibility",
subtitlePositionSet: "subtitle-position:set",
mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set",
subtitleAssSet: "subtitle-ass:set",
overlayDebugVisualizationSet: "overlay-debug-visualization:set",
secondarySubtitleSet: "secondary-subtitle:set",
secondarySubtitleMode: "secondary-subtitle:mode",
subsyncOpenManual: "subsync:open-manual",
kikuFieldGroupingRequest: "kiku:field-grouping-request",
runtimeOptionsChanged: "runtime-options:changed",
runtimeOptionsOpen: "runtime-options:open",
},
} as const;
export type RendererToMainInvokeChannel =
(typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke];
export type RendererToMainSendChannel =
(typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend];
export type MainToRendererEventChannel =
(typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent];

View File

@@ -1,19 +0,0 @@
import { ipcMain, IpcMainEvent } from "electron";
import {
RendererToMainInvokeChannel,
RendererToMainSendChannel,
} from "./contract";
export function onRendererSend(
channel: RendererToMainSendChannel,
listener: (event: IpcMainEvent, ...args: any[]) => void,
): void {
ipcMain.on(channel, listener);
}
export function handleRendererInvoke(
channel: RendererToMainInvokeChannel,
handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown,
): void {
ipcMain.handle(channel, handler);
}

View File

@@ -1,27 +0,0 @@
import { ipcRenderer, IpcRendererEvent } from "electron";
import {
MainToRendererEventChannel,
RendererToMainInvokeChannel,
RendererToMainSendChannel,
} from "./contract";
export function invokeFromRenderer<T>(
channel: RendererToMainInvokeChannel,
...args: unknown[]
): Promise<T> {
return ipcRenderer.invoke(channel, ...args) as Promise<T>;
}
export function sendFromRenderer(
channel: RendererToMainSendChannel,
...args: unknown[]
): void {
ipcRenderer.send(channel, ...args);
}
export function onMainEvent(
channel: MainToRendererEventChannel,
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
): void {
ipcRenderer.on(channel, listener);
}

View File

@@ -1,72 +0,0 @@
import { AppContext } from "../../core/app-context";
import { SubminerModule } from "../../core/module";
import {
JimakuApiResponse,
JimakuDownloadQuery,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
} from "../../types";
export class JimakuModule implements SubminerModule<AppContext> {
readonly id = "jimaku";
private context: AppContext["jimaku"] | undefined;
init(context: AppContext): void {
if (!context.jimaku) {
throw new Error("Jimaku context is missing");
}
this.context = context.jimaku;
}
getMediaInfo(): JimakuMediaInfo {
if (!this.context) {
return {
title: "",
season: null,
episode: null,
confidence: "low",
filename: "",
rawTitle: "",
};
}
return this.context.getMediaInfo();
}
searchEntries(
query: JimakuSearchQuery,
): Promise<JimakuApiResponse<JimakuEntry[]>> {
if (!this.context) {
return Promise.resolve({
ok: false,
error: { error: "Jimaku module not initialized" },
});
}
return this.context.searchEntries(query);
}
listFiles(
query: JimakuFilesQuery,
): Promise<JimakuApiResponse<JimakuFileEntry[]>> {
if (!this.context) {
return Promise.resolve({
ok: false,
error: { error: "Jimaku module not initialized" },
});
}
return this.context.listFiles(query);
}
downloadFile(query: JimakuDownloadQuery): Promise<JimakuDownloadResult> {
if (!this.context) {
return Promise.resolve({
ok: false,
error: { error: "Jimaku module not initialized" },
});
}
return this.context.downloadFile(query);
}
}

View File

@@ -1,61 +0,0 @@
import { AppContext } from "../../core/app-context";
import { SubminerModule } from "../../core/module";
import { RuntimeOptionsManager } from "../../runtime-options";
import {
AnkiConnectConfig,
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
} from "../../types";
export class RuntimeOptionsModule implements SubminerModule<AppContext> {
readonly id = "runtime-options";
private manager: RuntimeOptionsManager | null = null;
init(context: AppContext): void {
if (!context.runtimeOptions) {
throw new Error("Runtime options context is missing");
}
this.manager = new RuntimeOptionsManager(
context.runtimeOptions.getAnkiConfig,
{
applyAnkiPatch: context.runtimeOptions.applyAnkiPatch,
onOptionsChanged: context.runtimeOptions.onOptionsChanged,
},
);
}
listOptions(): RuntimeOptionState[] {
return this.manager ? this.manager.listOptions() : [];
}
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
return this.manager?.getOptionValue(id);
}
setOptionValue(
id: RuntimeOptionId,
value: RuntimeOptionValue,
): RuntimeOptionApplyResult {
if (!this.manager) {
return { ok: false, error: "Runtime options manager unavailable" };
}
return this.manager.setOptionValue(id, value);
}
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
if (!this.manager) {
return { ok: false, error: "Runtime options manager unavailable" };
}
return this.manager.cycleOption(id, direction);
}
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
if (!this.manager) {
return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {};
}
return this.manager.getEffectiveAnkiConnectConfig(baseConfig);
}
}

View File

@@ -1,78 +0,0 @@
import { AppContext } from "../../core/app-context";
import { SubminerModule } from "../../core/module";
import { SubsyncManualRunRequest, SubsyncResult } from "../../types";
export class SubsyncModule implements SubminerModule<AppContext> {
readonly id = "subsync";
private inProgress = false;
private context: AppContext["subsync"] | undefined;
init(context: AppContext): void {
if (!context.subsync) {
throw new Error("Subsync context is missing");
}
this.context = context.subsync;
}
isInProgress(): boolean {
return this.inProgress;
}
async triggerFromConfig(): Promise<void> {
if (!this.context) {
throw new Error("Subsync module not initialized");
}
if (this.inProgress) {
this.context.showOsd("Subsync already running");
return;
}
try {
if (this.context.getDefaultMode() === "manual") {
await this.context.openManualPicker();
this.context.showOsd("Subsync: choose engine and source");
return;
}
this.inProgress = true;
const result = await this.context.runWithSpinner(
() => this.context!.runAuto(),
"Subsync: syncing",
);
this.context.showOsd(result.message);
} catch (error) {
this.context.showOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
this.inProgress = false;
}
}
async runManual(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
if (!this.context) {
return { ok: false, message: "Subsync module not initialized" };
}
if (this.inProgress) {
const busy = "Subsync already running";
this.context.showOsd(busy);
return { ok: false, message: busy };
}
try {
this.inProgress = true;
const result = await this.context.runWithSpinner(
() => this.context!.runManual(request),
"Subsync: syncing",
);
this.context.showOsd(result.message);
return result;
} catch (error) {
const message = `Subsync failed: ${(error as Error).message}`;
this.context.showOsd(message);
return { ok: false, message };
} finally {
this.inProgress = false;
}
}
}

View File

@@ -64,6 +64,8 @@ interface Keybinding {
interface SubtitlePosition { interface SubtitlePosition {
yPercent: number; yPercent: number;
invisibleOffsetXPx?: number;
invisibleOffsetYPx?: number;
} }
type SecondarySubMode = "hidden" | "visible" | "hover"; type SecondarySubMode = "hidden" | "visible" | "hover";
@@ -342,12 +344,16 @@ const isMacOSPlatform =
// Linux passthrough forwarding is not reliable for this overlay; keep pointer // Linux passthrough forwarding is not reliable for this overlay; keep pointer
// routing local so hover lookup, drag-reposition, and key handling remain usable. // routing local so hover lookup, drag-reposition, and key handling remain usable.
const shouldToggleMouseIgnore = !isLinuxPlatform; const shouldToggleMouseIgnore = !isLinuxPlatform;
const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP";
const INVISIBLE_POSITION_STEP_PX = 1;
const INVISIBLE_POSITION_STEP_FAST_PX = 4;
let isOverSubtitle = false; let isOverSubtitle = false;
let isDragging = false; let isDragging = false;
let dragStartY = 0; let dragStartY = 0;
let startYPercent = 0; let startYPercent = 0;
let currentYPercent: number | null = null; let currentYPercent: number | null = null;
let persistedSubtitlePosition: SubtitlePosition = { yPercent: 10 };
let jimakuModalOpen = false; let jimakuModalOpen = false;
let jimakuEntries: JimakuEntry[] = []; let jimakuEntries: JimakuEntry[] = [];
let jimakuFiles: JimakuFileEntry[] = []; let jimakuFiles: JimakuFileEntry[] = [];
@@ -393,6 +399,15 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = {
let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
}; };
let invisiblePositionEditMode = false;
let invisiblePositionEditStartX = 0;
let invisiblePositionEditStartY = 0;
let invisibleSubtitleOffsetXPx = 0;
let invisibleSubtitleOffsetYPx = 0;
let invisibleLayoutBaseLeftPx = 0;
let invisibleLayoutBaseBottomPx: number | null = null;
let invisibleLayoutBaseTopPx: number | null = null;
let invisiblePositionEditHud: HTMLDivElement | null = null;
let currentInvisibleSubtitleLineCount = 1; let currentInvisibleSubtitleLineCount = 1;
let lastHoverSelectionKey = ""; let lastHoverSelectionKey = "";
let lastHoverSelectionNode: Text | null = null; let lastHoverSelectionNode: Text | null = null;
@@ -554,7 +569,8 @@ function handleMouseLeave(): void {
!jimakuModalOpen && !jimakuModalOpen &&
!kikuModalOpen && !kikuModalOpen &&
!runtimeOptionsModalOpen && !runtimeOptionsModalOpen &&
!subsyncModalOpen !subsyncModalOpen &&
!invisiblePositionEditMode
) { ) {
overlay.classList.remove("interactive"); overlay.classList.remove("interactive");
if (shouldToggleMouseIgnore) { if (shouldToggleMouseIgnore) {
@@ -591,10 +607,52 @@ function applyYPercent(yPercent: number): void {
subtitleContainer.style.marginBottom = `${marginBottom}px`; subtitleContainer.style.marginBottom = `${marginBottom}px`;
} }
function updatePersistedSubtitlePosition(position: SubtitlePosition | null): void {
const nextYPercent =
position && typeof position.yPercent === "number" && Number.isFinite(position.yPercent)
? position.yPercent
: persistedSubtitlePosition.yPercent;
const nextXOffset =
position && typeof position.invisibleOffsetXPx === "number" && Number.isFinite(position.invisibleOffsetXPx)
? position.invisibleOffsetXPx
: 0;
const nextYOffset =
position && typeof position.invisibleOffsetYPx === "number" && Number.isFinite(position.invisibleOffsetYPx)
? position.invisibleOffsetYPx
: 0;
persistedSubtitlePosition = {
yPercent: nextYPercent,
invisibleOffsetXPx: nextXOffset,
invisibleOffsetYPx: nextYOffset,
};
}
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
const nextPosition: SubtitlePosition = {
yPercent:
typeof patch.yPercent === "number" && Number.isFinite(patch.yPercent)
? patch.yPercent
: persistedSubtitlePosition.yPercent,
invisibleOffsetXPx:
typeof patch.invisibleOffsetXPx === "number" &&
Number.isFinite(patch.invisibleOffsetXPx)
? patch.invisibleOffsetXPx
: persistedSubtitlePosition.invisibleOffsetXPx ?? 0,
invisibleOffsetYPx:
typeof patch.invisibleOffsetYPx === "number" &&
Number.isFinite(patch.invisibleOffsetYPx)
? patch.invisibleOffsetYPx
: persistedSubtitlePosition.invisibleOffsetYPx ?? 0,
};
persistedSubtitlePosition = nextPosition;
window.electronAPI.saveSubtitlePosition(nextPosition);
}
function applyStoredSubtitlePosition( function applyStoredSubtitlePosition(
position: SubtitlePosition | null, position: SubtitlePosition | null,
source: string, source: string,
): void { ): void {
updatePersistedSubtitlePosition(position);
if (position && position.yPercent !== undefined) { if (position && position.yPercent !== undefined) {
applyYPercent(position.yPercent); applyYPercent(position.yPercent);
console.log( console.log(
@@ -612,6 +670,66 @@ function applyStoredSubtitlePosition(
} }
} }
function applyInvisibleSubtitleOffsetPosition(): void {
const nextLeft = invisibleLayoutBaseLeftPx + invisibleSubtitleOffsetXPx;
subtitleContainer.style.left = `${nextLeft}px`;
if (invisibleLayoutBaseBottomPx !== null) {
subtitleContainer.style.bottom = `${Math.max(0, invisibleLayoutBaseBottomPx + invisibleSubtitleOffsetYPx)}px`;
subtitleContainer.style.top = "";
return;
}
if (invisibleLayoutBaseTopPx !== null) {
subtitleContainer.style.top = `${Math.max(0, invisibleLayoutBaseTopPx - invisibleSubtitleOffsetYPx)}px`;
subtitleContainer.style.bottom = "";
}
}
function updateInvisiblePositionEditHud(): void {
if (!invisiblePositionEditHud) return;
invisiblePositionEditHud.textContent =
`Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(invisibleSubtitleOffsetXPx)} y:${Math.round(invisibleSubtitleOffsetYPx)}`;
}
function setInvisiblePositionEditMode(enabled: boolean): void {
if (!isInvisibleLayer) return;
if (invisiblePositionEditMode === enabled) return;
invisiblePositionEditMode = enabled;
document.body.classList.toggle("invisible-position-edit", enabled);
if (enabled) {
invisiblePositionEditStartX = invisibleSubtitleOffsetXPx;
invisiblePositionEditStartY = invisibleSubtitleOffsetYPx;
overlay.classList.add("interactive");
if (shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
} else if (!isOverSubtitle && !isAnySettingsModalOpen()) {
overlay.classList.remove("interactive");
if (shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
updateInvisiblePositionEditHud();
}
function applyInvisibleStoredSubtitlePosition(
position: SubtitlePosition | null,
source: string,
): void {
updatePersistedSubtitlePosition(position);
invisibleSubtitleOffsetXPx = persistedSubtitlePosition.invisibleOffsetXPx ?? 0;
invisibleSubtitleOffsetYPx = persistedSubtitlePosition.invisibleOffsetYPx ?? 0;
applyInvisibleSubtitleOffsetPosition();
console.log(
"[invisible-overlay] Applied subtitle offset from",
source,
`${invisibleSubtitleOffsetXPx}px`,
`${invisibleSubtitleOffsetYPx}px`,
);
updateInvisiblePositionEditHud();
}
function applySubtitleFontSize(fontSize: number): void { function applySubtitleFontSize(fontSize: number): void {
const clampedSize = Math.max(10, fontSize); const clampedSize = Math.max(10, fontSize);
subtitleRoot.style.fontSize = `${clampedSize}px`; subtitleRoot.style.fontSize = `${clampedSize}px`;
@@ -944,6 +1062,15 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics(
} }
} }
} }
invisibleLayoutBaseLeftPx = parseFloat(subtitleContainer.style.left) || 0;
invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom))
? parseFloat(subtitleContainer.style.bottom)
: null;
invisibleLayoutBaseTopPx = Number.isFinite(parseFloat(subtitleContainer.style.top))
? parseFloat(subtitleContainer.style.top)
: null;
applyInvisibleSubtitleOffsetPosition();
updateInvisiblePositionEditHud();
console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source); console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source);
} }
@@ -1860,7 +1987,7 @@ function setupDragging(): void {
subtitleContainer.style.cursor = ""; subtitleContainer.style.cursor = "";
const yPercent = getCurrentYPercent(); const yPercent = getCurrentYPercent();
window.electronAPI.saveSubtitlePosition({ yPercent }); persistSubtitlePositionPatch({ yPercent });
} }
}); });
@@ -2017,6 +2144,91 @@ function keyEventToString(e: KeyboardEvent): string {
return parts.join("+"); return parts.join("+");
} }
function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean {
return (
e.code === INVISIBLE_POSITION_EDIT_TOGGLE_CODE &&
!e.altKey &&
e.shiftKey &&
(e.ctrlKey || e.metaKey)
);
}
function saveInvisiblePositionEdit(): void {
persistSubtitlePositionPatch({
invisibleOffsetXPx: invisibleSubtitleOffsetXPx,
invisibleOffsetYPx: invisibleSubtitleOffsetYPx,
});
setInvisiblePositionEditMode(false);
}
function cancelInvisiblePositionEdit(): void {
invisibleSubtitleOffsetXPx = invisiblePositionEditStartX;
invisibleSubtitleOffsetYPx = invisiblePositionEditStartY;
applyInvisibleSubtitleOffsetPosition();
setInvisiblePositionEditMode(false);
}
function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean {
if (!isInvisibleLayer) return false;
if (isInvisiblePositionToggleShortcut(e)) {
e.preventDefault();
if (invisiblePositionEditMode) {
cancelInvisiblePositionEdit();
} else {
setInvisiblePositionEditMode(true);
}
return true;
}
if (!invisiblePositionEditMode) return false;
const step = e.shiftKey ? INVISIBLE_POSITION_STEP_FAST_PX : INVISIBLE_POSITION_STEP_PX;
if (e.key === "Escape") {
e.preventDefault();
cancelInvisiblePositionEdit();
return true;
}
if (e.key === "Enter" || ((e.ctrlKey || e.metaKey) && e.code === "KeyS")) {
e.preventDefault();
saveInvisiblePositionEdit();
return true;
}
if (
e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "ArrowLeft" ||
e.key === "ArrowRight" ||
e.key === "h" ||
e.key === "j" ||
e.key === "k" ||
e.key === "l" ||
e.key === "H" ||
e.key === "J" ||
e.key === "K" ||
e.key === "L"
) {
e.preventDefault();
if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
invisibleSubtitleOffsetYPx += step;
} else if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
invisibleSubtitleOffsetYPx -= step;
} else if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") {
invisibleSubtitleOffsetXPx -= step;
} else if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") {
invisibleSubtitleOffsetXPx += step;
}
applyInvisibleSubtitleOffsetPosition();
updateInvisiblePositionEditHud();
return true;
}
return true;
}
let keybindingsMap = new Map<string, (string | number)[]>(); let keybindingsMap = new Map<string, (string | number)[]>();
type ChordAction = type ChordAction =
@@ -2073,6 +2285,7 @@ async function setupMpvInputForwarding(): Promise<void> {
document.addEventListener("keydown", (e: KeyboardEvent) => { document.addEventListener("keydown", (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
if (yomitanPopup) return; if (yomitanPopup) return;
if (handleInvisiblePositionEditKeydown(e)) return;
if (runtimeOptionsModalOpen) { if (runtimeOptionsModalOpen) {
handleRuntimeOptionsKeydown(e); handleRuntimeOptionsKeydown(e);
@@ -2202,6 +2415,16 @@ function setupSelectionObserver(): void {
}); });
} }
function setupInvisiblePositionEditHud(): void {
if (!isInvisibleLayer) return;
const hud = document.createElement("div");
hud.id = "invisiblePositionEditHud";
hud.className = "invisible-position-edit-hud";
overlay.appendChild(hud);
invisiblePositionEditHud = hud;
updateInvisiblePositionEditHud();
}
function setupYomitanObserver(): void { function setupYomitanObserver(): void {
const observer = new MutationObserver((mutations: MutationRecord[]) => { const observer = new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) { for (const mutation of mutations) {
@@ -2340,13 +2563,15 @@ async function init(): Promise<void> {
renderSubtitle(data); renderSubtitle(data);
}); });
if (!isInvisibleLayer) {
window.electronAPI.onSubtitlePosition( window.electronAPI.onSubtitlePosition(
(position: SubtitlePosition | null) => { (position: SubtitlePosition | null) => {
if (isInvisibleLayer) {
applyInvisibleStoredSubtitlePosition(position, "media-change");
} else {
applyStoredSubtitlePosition(position, "media-change"); applyStoredSubtitlePosition(position, "media-change");
}
}, },
); );
}
if (isInvisibleLayer) { if (isInvisibleLayer) {
window.electronAPI.onMpvSubtitleRenderMetrics( window.electronAPI.onMpvSubtitleRenderMetrics(
@@ -2380,6 +2605,7 @@ async function init(): Promise<void> {
hoverTarget.addEventListener("mouseenter", handleMouseEnter); hoverTarget.addEventListener("mouseenter", handleMouseEnter);
hoverTarget.addEventListener("mouseleave", handleMouseLeave); hoverTarget.addEventListener("mouseleave", handleMouseLeave);
setupInvisibleHoverSelection(); setupInvisibleHoverSelection();
setupInvisiblePositionEditHud();
secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter);
secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave);
@@ -2503,6 +2729,8 @@ async function init(): Promise<void> {
setupResizeHandler(); setupResizeHandler();
if (isInvisibleLayer) { if (isInvisibleLayer) {
const position = await window.electronAPI.getSubtitlePosition();
applyInvisibleStoredSubtitlePosition(position, "startup");
const metrics = await window.electronAPI.getMpvSubtitleRenderMetrics(); const metrics = await window.electronAPI.getMpvSubtitleRenderMetrics();
applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "startup"); applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "startup");
} else { } else {

View File

@@ -350,6 +350,39 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
text-shadow: none !important; text-shadow: none !important;
} }
.invisible-position-edit-hud {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
max-width: min(90vw, 1100px);
padding: 6px 10px;
border-radius: 8px;
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.95);
background: rgba(22, 24, 36, 0.88);
border: 1px solid rgba(130, 150, 255, 0.55);
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
}
body.layer-invisible.invisible-position-edit .invisible-position-edit-hud {
opacity: 1;
}
body.layer-invisible.invisible-position-edit #subtitleRoot,
body.layer-invisible.invisible-position-edit #subtitleRoot .word,
body.layer-invisible.invisible-position-edit #subtitleRoot .c {
color: #ed8796 !important;
-webkit-text-fill-color: #ed8796 !important;
-webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
paint-order: stroke fill !important;
text-shadow: none !important;
}
#secondarySubContainer { #secondarySubContainer {
position: absolute; position: absolute;
top: 40px; top: 40px;

View File

@@ -60,6 +60,8 @@ export interface WindowGeometry {
export interface SubtitlePosition { export interface SubtitlePosition {
yPercent: number; yPercent: number;
invisibleOffsetXPx?: number;
invisibleOffsetYPx?: number;
} }
export interface SubtitleStyle { export interface SubtitleStyle {