diff --git a/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md b/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md index b7983f0..835ae34 100644 --- a/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md +++ b/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md @@ -4,11 +4,12 @@ title: Refactor runtime services per plan.md status: Done assignee: [] created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:50' +updated_date: '2026-02-11 03:35' labels: [] dependencies: [] references: - plan.md +ordinal: 1000 --- ## Description diff --git a/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md b/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md index 3b7a866..eca139f 100644 --- a/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md +++ b/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' labels: [] dependencies: [] references: @@ -13,6 +13,7 @@ references: - src/main.ts - src/core/services/index.ts parent_task_id: TASK-1 +ordinal: 11000 --- ## Description diff --git a/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md b/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md index a65b18b..a707607 100644 --- a/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md +++ b/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:00' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.1 @@ -16,6 +16,7 @@ references: - src/core/services/tokenizer-service.ts - src/core/services/app-lifecycle-deps-runtime-service.ts parent_task_id: TASK-1 +ordinal: 9000 --- ## Description diff --git a/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md b/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md index ac6a42e..af54b3d 100644 --- a/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md +++ b/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:17' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.2 @@ -17,6 +17,7 @@ references: - src/core/services/numeric-shortcut-session-service.ts - src/core/services/app-ready-runtime-service.ts parent_task_id: TASK-1 +ordinal: 5000 --- ## Description diff --git a/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md b/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md index 9738c95..d610434 100644 --- a/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md +++ b/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:50' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.3 @@ -15,6 +15,7 @@ references: - src/core/services/overlay-visibility-service.ts - src/core/services/tokenizer-deps-runtime-service.ts parent_task_id: TASK-1 +ordinal: 2000 --- ## Description diff --git a/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md b/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md index 3386eb8..c5bd2e5 100644 --- a/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md +++ b/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:36' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.4 @@ -16,6 +16,7 @@ references: - src/core/services/tokenizer-service.ts - src/core/services/cli-command-service.ts parent_task_id: TASK-1 +ordinal: 4000 --- ## Description diff --git a/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md b/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md index 4e723aa..108936d 100644 --- a/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md +++ b/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md @@ -4,13 +4,14 @@ title: 'Phase 6 (Optional): Reorganize services by domain directories' status: Done assignee: [] created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:41' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.5 references: - plan.md parent_task_id: TASK-1 +ordinal: 3000 --- ## Description diff --git a/backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md b/backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md new file mode 100644 index 0000000..6a937a4 --- /dev/null +++ b/backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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 + diff --git a/backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md b/backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md new file mode 100644 index 0000000..7ff0794 --- /dev/null +++ b/backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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`. + + +## Implementation Notes + + +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) + diff --git a/backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md b/backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md new file mode 100644 index 0000000..13a8fdc --- /dev/null +++ b/backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md @@ -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 + + +Add dedicated tests for `overlay-shortcut-handler.ts`, covering shortcut runtime handlers, fallback behavior, and key edge/error paths. + + +## Acceptance Criteria + +- [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. + + +## Implementation Notes + + +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). + diff --git a/backlog/tasks/task-2.3 - Add-tests-for-mining-service.md b/backlog/tasks/task-2.3 - Add-tests-for-mining-service.md new file mode 100644 index 0000000..dafe779 --- /dev/null +++ b/backlog/tasks/task-2.3 - Add-tests-for-mining-service.md @@ -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 + + +Add dedicated behavior tests for `mining-service.ts` covering sentence/card mining orchestration and error boundaries. + + +## Acceptance Criteria + +- [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. + + +## Implementation Notes + + +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). + diff --git a/backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md b/backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md new file mode 100644 index 0000000..5fcf819 --- /dev/null +++ b/backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md @@ -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 + + +Add dedicated tests for `anki-jimaku-service.ts` focusing on IPC handler registration, request dispatch, and error handling behavior. + + +## Acceptance Criteria + +- [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. + + +## Implementation Notes + + +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). + diff --git a/backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md b/backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md new file mode 100644 index 0000000..61fcb82 --- /dev/null +++ b/backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md @@ -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 + + +Execute manual desktop smoke checks in an MPV-enabled environment to validate overlay rendering and key user workflows not fully covered by automated tests. + + +## Acceptance Criteria + +- [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. + + +## Implementation Notes + + +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. + diff --git a/backlog/tasks/task-3 - move-invisible-subtitles.md b/backlog/tasks/task-3 - move-invisible-subtitles.md new file mode 100644 index 0000000..528cd29 --- /dev/null +++ b/backlog/tasks/task-3 - move-invisible-subtitles.md @@ -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 + + +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 + + +## Implementation Notes + + +- 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). + diff --git a/package.json b/package.json index 189f749..dee4887 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "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/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", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/core/action-bus.ts b/src/core/action-bus.ts deleted file mode 100644 index 92e7fbe..0000000 --- a/src/core/action-bus.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type ActionWithType = { type: string }; - -export type ActionHandler = ( - action: TAction, -) => void | Promise; - -export class ActionBus { - private handlers = new Map>(); - - register(type: TAction["type"], handler: ActionHandler): void { - this.handlers.set(type, handler); - } - - async dispatch(action: TAction): Promise { - const handler = this.handlers.get(action.type); - if (!handler) { - throw new Error(`No handler registered for action: ${action.type}`); - } - await handler(action); - } -} diff --git a/src/core/actions.ts b/src/core/actions.ts deleted file mode 100644 index aa4d61a..0000000 --- a/src/core/actions.ts +++ /dev/null @@ -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" }; diff --git a/src/core/app-context.ts b/src/core/app-context.ts deleted file mode 100644 index e907fe7..0000000 --- a/src/core/app-context.ts +++ /dev/null @@ -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) => void; - onOptionsChanged: (options: RuntimeOptionState[]) => void; -} - -export interface AppContext { - runtimeOptions?: RuntimeOptionsModuleContext; - jimaku?: { - getMediaInfo: () => JimakuMediaInfo; - searchEntries: ( - query: JimakuSearchQuery, - ) => Promise>; - listFiles: ( - query: JimakuFilesQuery, - ) => Promise>; - downloadFile: ( - query: JimakuDownloadQuery, - ) => Promise; - }; - subsync?: { - getDefaultMode: () => SubsyncMode; - openManualPicker: () => Promise; - runAuto: () => Promise; - runManual: (request: SubsyncManualRunRequest) => Promise; - showOsd: (message: string) => void; - runWithSpinner: (task: () => Promise, label?: string) => Promise; - }; -} diff --git a/src/core/module-registry.ts b/src/core/module-registry.ts deleted file mode 100644 index 72795ad..0000000 --- a/src/core/module-registry.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SubminerModule } from "./module"; - -export class ModuleRegistry { - private readonly modules: SubminerModule[] = []; - - register(module: SubminerModule): 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 { - for (const module of this.modules) { - if (module.init) { - await module.init(context); - } - } - } - - async startAll(): Promise { - for (const module of this.modules) { - if (module.start) { - await module.start(); - } - } - } - - async stopAll(): Promise { - for (const module of [...this.modules].reverse()) { - if (module.stop) { - await module.stop(); - } - } - } -} diff --git a/src/core/module.ts b/src/core/module.ts deleted file mode 100644 index 0e69a10..0000000 --- a/src/core/module.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SubminerModule { - id: string; - init?: (context: TContext) => void | Promise; - start?: () => void | Promise; - stop?: () => void | Promise; -} diff --git a/src/core/services/anki-jimaku-service.test.ts b/src/core/services/anki-jimaku-service.test.ts new file mode 100644 index 0000000..53a361a --- /dev/null +++ b/src/core/services/anki-jimaku-service.test.ts @@ -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 unknown>; + state: { + ankiIntegration: unknown; + fieldGroupingResolver: ((choice: unknown) => void) | null; + patches: boolean[]; + broadcasts: number; + fetchCalls: Array<{ endpoint: string; query?: Record }>; + 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 }>, + 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 }); + 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 unknown> = {}; + registerAnkiJimakuIpcRuntimeService( + options, + (deps) => { + registered = deps as unknown as Record 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"] }, + ]); +}); diff --git a/src/core/services/anki-jimaku-service.ts b/src/core/services/anki-jimaku-service.ts index 819a7c2..db31912 100644 --- a/src/core/services/anki-jimaku-service.ts +++ b/src/core/services/anki-jimaku-service.ts @@ -59,8 +59,9 @@ export interface AnkiJimakuIpcRuntimeOptions { export function registerAnkiJimakuIpcRuntimeService( options: AnkiJimakuIpcRuntimeOptions, + registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers, ): void { - registerAnkiJimakuIpcHandlers({ + registerHandlers({ setAnkiConnectEnabled: (enabled) => { options.patchAnkiConnectEnabled(enabled); const config = options.getResolvedConfig(); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index abb8e17..72c3236 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -2,7 +2,7 @@ export { TexthookerService } from "./texthooker-service"; export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service"; export { registerGlobalShortcutsService } from "./shortcut-service"; export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service"; -export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; +export { shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; export { refreshOverlayShortcutsRuntimeService, registerOverlayShortcutsService, diff --git a/src/core/services/mining-service.test.ts b/src/core/services/mining-service.test.ts new file mode 100644 index 0000000..ef0c427 --- /dev/null +++ b/src/core/services/mining-service.test.ts @@ -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"))); +}); diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts new file mode 100644 index 0000000..f80c994 --- /dev/null +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -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 { + 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 = {}) { + 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); +}); diff --git a/src/core/services/subtitle-position-service.ts b/src/core/services/subtitle-position-service.ts index c09b333..98f3cd7 100644 --- a/src/core/services/subtitle-position-service.ts +++ b/src/core/services/subtitle-position-service.ts @@ -80,7 +80,20 @@ export function loadSubtitlePositionService(options: { typeof parsed.yPercent === "number" && 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; } catch (err) { diff --git a/src/ipc/contract.ts b/src/ipc/contract.ts deleted file mode 100644 index 8e2a403..0000000 --- a/src/ipc/contract.ts +++ /dev/null @@ -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]; diff --git a/src/ipc/main-api.ts b/src/ipc/main-api.ts deleted file mode 100644 index f400148..0000000 --- a/src/ipc/main-api.ts +++ /dev/null @@ -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); -} diff --git a/src/ipc/renderer-api.ts b/src/ipc/renderer-api.ts deleted file mode 100644 index 3bfea1d..0000000 --- a/src/ipc/renderer-api.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ipcRenderer, IpcRendererEvent } from "electron"; -import { - MainToRendererEventChannel, - RendererToMainInvokeChannel, - RendererToMainSendChannel, -} from "./contract"; - -export function invokeFromRenderer( - channel: RendererToMainInvokeChannel, - ...args: unknown[] -): Promise { - return ipcRenderer.invoke(channel, ...args) as Promise; -} - -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); -} diff --git a/src/modules/jimaku/module.ts b/src/modules/jimaku/module.ts deleted file mode 100644 index 662da69..0000000 --- a/src/modules/jimaku/module.ts +++ /dev/null @@ -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 { - 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> { - if (!this.context) { - return Promise.resolve({ - ok: false, - error: { error: "Jimaku module not initialized" }, - }); - } - return this.context.searchEntries(query); - } - - listFiles( - query: JimakuFilesQuery, - ): Promise> { - if (!this.context) { - return Promise.resolve({ - ok: false, - error: { error: "Jimaku module not initialized" }, - }); - } - return this.context.listFiles(query); - } - - downloadFile(query: JimakuDownloadQuery): Promise { - if (!this.context) { - return Promise.resolve({ - ok: false, - error: { error: "Jimaku module not initialized" }, - }); - } - return this.context.downloadFile(query); - } -} diff --git a/src/modules/runtime-options/module.ts b/src/modules/runtime-options/module.ts deleted file mode 100644 index 2bbf1ab..0000000 --- a/src/modules/runtime-options/module.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/src/modules/subsync/module.ts b/src/modules/subsync/module.ts deleted file mode 100644 index 250b65a..0000000 --- a/src/modules/subsync/module.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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; - } - } -} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 76f2a76..aba04fe 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -64,6 +64,8 @@ interface Keybinding { interface SubtitlePosition { yPercent: number; + invisibleOffsetXPx?: number; + invisibleOffsetYPx?: number; } type SecondarySubMode = "hidden" | "visible" | "hover"; @@ -342,12 +344,16 @@ const isMacOSPlatform = // Linux passthrough forwarding is not reliable for this overlay; keep pointer // routing local so hover lookup, drag-reposition, and key handling remain usable. 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 isDragging = false; let dragStartY = 0; let startYPercent = 0; let currentYPercent: number | null = null; +let persistedSubtitlePosition: SubtitlePosition = { yPercent: 10 }; let jimakuModalOpen = false; let jimakuEntries: JimakuEntry[] = []; let jimakuFiles: JimakuFileEntry[] = []; @@ -393,6 +399,15 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = { let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { ...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 lastHoverSelectionKey = ""; let lastHoverSelectionNode: Text | null = null; @@ -554,7 +569,8 @@ function handleMouseLeave(): void { !jimakuModalOpen && !kikuModalOpen && !runtimeOptionsModalOpen && - !subsyncModalOpen + !subsyncModalOpen && + !invisiblePositionEditMode ) { overlay.classList.remove("interactive"); if (shouldToggleMouseIgnore) { @@ -591,10 +607,52 @@ function applyYPercent(yPercent: number): void { 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): 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( position: SubtitlePosition | null, source: string, ): void { + updatePersistedSubtitlePosition(position); if (position && position.yPercent !== undefined) { applyYPercent(position.yPercent); 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 { const clampedSize = Math.max(10, fontSize); 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); } @@ -1860,7 +1987,7 @@ function setupDragging(): void { subtitleContainer.style.cursor = ""; const yPercent = getCurrentYPercent(); - window.electronAPI.saveSubtitlePosition({ yPercent }); + persistSubtitlePositionPatch({ yPercent }); } }); @@ -2017,6 +2144,91 @@ function keyEventToString(e: KeyboardEvent): string { 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(); type ChordAction = @@ -2073,6 +2285,7 @@ async function setupMpvInputForwarding(): Promise { document.addEventListener("keydown", (e: KeyboardEvent) => { const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); if (yomitanPopup) return; + if (handleInvisiblePositionEditKeydown(e)) return; if (runtimeOptionsModalOpen) { 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 { const observer = new MutationObserver((mutations: MutationRecord[]) => { for (const mutation of mutations) { @@ -2340,13 +2563,15 @@ async function init(): Promise { renderSubtitle(data); }); - if (!isInvisibleLayer) { - window.electronAPI.onSubtitlePosition( - (position: SubtitlePosition | null) => { + window.electronAPI.onSubtitlePosition( + (position: SubtitlePosition | null) => { + if (isInvisibleLayer) { + applyInvisibleStoredSubtitlePosition(position, "media-change"); + } else { applyStoredSubtitlePosition(position, "media-change"); - }, - ); - } + } + }, + ); if (isInvisibleLayer) { window.electronAPI.onMpvSubtitleRenderMetrics( @@ -2380,6 +2605,7 @@ async function init(): Promise { hoverTarget.addEventListener("mouseenter", handleMouseEnter); hoverTarget.addEventListener("mouseleave", handleMouseLeave); setupInvisibleHoverSelection(); + setupInvisiblePositionEditHud(); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); @@ -2503,6 +2729,8 @@ async function init(): Promise { setupResizeHandler(); if (isInvisibleLayer) { + const position = await window.electronAPI.getSubtitlePosition(); + applyInvisibleStoredSubtitlePosition(position, "startup"); const metrics = await window.electronAPI.getMpvSubtitleRenderMetrics(); applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "startup"); } else { diff --git a/src/renderer/style.css b/src/renderer/style.css index e03b344..ecf4458 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -350,6 +350,39 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { 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 { position: absolute; top: 40px; diff --git a/src/types.ts b/src/types.ts index f5a8499..49d225b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,6 +60,8 @@ export interface WindowGeometry { export interface SubtitlePosition { yPercent: number; + invisibleOffsetXPx?: number; + invisibleOffsetYPx?: number; } export interface SubtitleStyle {