mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Complete runtime service follow-ups and invisible subtitle edit mode
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
45
backlog/tasks/task-2.3 - Add-tests-for-mining-service.md
Normal file
45
backlog/tasks/task-2.3 - Add-tests-for-mining-service.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
28
backlog/tasks/task-3 - move-invisible-subtitles.md
Normal file
28
backlog/tasks/task-3 - move-invisible-subtitles.md
Normal 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 -->
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" };
|
|
||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
228
src/core/services/anki-jimaku-service.test.ts
Normal file
228
src/core/services/anki-jimaku-service.test.ts
Normal 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"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
168
src/core/services/mining-service.test.ts
Normal file
168
src/core/services/mining-service.test.ts
Normal 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")));
|
||||||
|
});
|
||||||
256
src/core/services/overlay-shortcut-handler.test.ts
Normal file
256
src/core/services/overlay-shortcut-handler.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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];
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user