From 75442a4648a3a73702565bc71053de648b3ac721 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 26 Feb 2026 16:40:51 -0800 Subject: [PATCH] feat: bind overlay state to secondary subtitle mpv visibility --- config.example.jsonc | 23 +- docs/architecture.md | 31 +- docs/index.md | 4 +- docs/mining-workflow.md | 38 +-- ...-02-26-secondary-subtitles-main-overlay.md | 55 ++++ package.json | 4 +- src/config/resolve/core-domains.ts | 19 -- src/core/services/index.ts | 6 +- src/core/services/mpv-properties.ts | 2 + src/core/services/mpv-protocol.test.ts | 32 ++ src/core/services/mpv-protocol.ts | 17 + src/core/services/mpv.test.ts | 26 ++ src/core/services/mpv.ts | 15 +- src/core/services/overlay-manager.test.ts | 95 +----- src/core/services/overlay-manager.ts | 63 +--- src/core/services/overlay-window-geometry.ts | 41 --- src/core/services/overlay-window.test.ts | 37 --- src/core/services/overlay-window.ts | 46 +-- src/main.ts | 309 ++++++++---------- .../runtime/app-lifecycle-actions.test.ts | 8 +- src/main/runtime/app-lifecycle-actions.ts | 8 +- .../app-lifecycle-main-cleanup.test.ts | 2 + .../runtime/app-lifecycle-main-cleanup.ts | 3 + .../startup-lifecycle-composer.test.ts | 4 +- src/main/runtime/mpv-main-event-actions.ts | 2 + .../runtime/mpv-main-event-bindings.test.ts | 4 + src/main/runtime/mpv-main-event-bindings.ts | 5 + .../runtime/mpv-main-event-main-deps.test.ts | 6 + src/main/runtime/mpv-main-event-main-deps.ts | 5 + .../overlay-mpv-sub-visibility.test.ts | 171 ++++++++++ .../runtime/overlay-mpv-sub-visibility.ts | 147 +++++++++ .../overlay-window-factory-main-deps.test.ts | 20 +- .../overlay-window-factory-main-deps.ts | 37 +-- .../runtime/overlay-window-factory.test.ts | 36 +- src/main/runtime/overlay-window-factory.ts | 27 +- .../overlay-window-runtime-handlers.test.ts | 19 +- .../overlay-window-runtime-handlers.ts | 20 -- src/main/state.ts | 10 +- src/preload.ts | 133 +++++--- src/renderer/error-recovery.test.ts | 76 ++++- src/renderer/error-recovery.ts | 3 +- src/renderer/renderer.ts | 146 +++------ src/renderer/style.css | 241 ++++++-------- src/renderer/subtitle-render.test.ts | 118 +++++-- src/renderer/subtitle-render.ts | 135 ++++++-- src/renderer/utils/platform.ts | 28 +- src/shared/ipc/contracts.ts | 5 +- src/types.ts | 19 +- 48 files changed, 1231 insertions(+), 1070 deletions(-) create mode 100644 docs/plans/2026-02-26-secondary-subtitles-main-overlay.md delete mode 100644 src/core/services/overlay-window-geometry.ts delete mode 100644 src/core/services/overlay-window.test.ts create mode 100644 src/main/runtime/overlay-mpv-sub-visibility.test.ts create mode 100644 src/main/runtime/overlay-mpv-sub-visibility.ts diff --git a/config.example.jsonc b/config.example.jsonc index f7f4926..d32ca21 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -13,11 +13,11 @@ "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false // ========================================== - // Visible Overlay Subtitle Binding - // Control whether visible overlay toggles also toggle MPV subtitle visibility. - // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. + // Visible Overlay Subtitle Binding (Legacy) + // Backward-compatible key. Runtime ignores this value. + // MPV subtitles are automatically hidden whenever any overlay subtitle mode is visible. // ========================================== - "bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false + "bind_visible_overlay_to_mpv_sub_visibility": true, // Legacy compatibility key (runtime no-op). Values: true | false // ========================================== // Texthooker Server @@ -53,7 +53,6 @@ // ========================================== "shortcuts": { "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. - "toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting. "copySubtitle": "CommandOrControl+C", // Copy subtitle setting. "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. @@ -68,16 +67,6 @@ "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. - // ========================================== - // Invisible Overlay - // Startup behavior for the invisible interactive subtitle mining layer. - // Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel. - // This edit-mode shortcut is fixed and is not currently configurable. - // ========================================== - "invisibleOverlay": { - "startupVisibility": "platform-default" // Startup visibility setting. - }, // Startup behavior for the invisible interactive subtitle mining layer. - // ========================================== // Keybindings (MPV Commands) // Extra keybindings that are merged with built-in defaults. @@ -123,9 +112,11 @@ // Hot-reload: subtitle style changes apply live without restarting SubMiner. // ========================================== "subtitleStyle": { + // Additional CSS declarations are also allowed and applied directly to subtitle roots/containers (for example: lineHeight, textShadow, -webkit-text-stroke, backdropFilter). "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false - "hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv. + "hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in the overlay. + "hoverTokenBackgroundColor": "#363a4fd6", // CSS color used for hovered subtitle token background highlight in the overlay. "fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting. "fontSize": 35, // Font size setting. "fontColor": "#cad3f5", // Font color setting. diff --git a/docs/architecture.md b/docs/architecture.md index 257f45b..afcbca6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ src/ ### Service Layer (`src/core/services/`) -- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-window-geometry.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts` +- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts` - **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts` - **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts` - **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts` @@ -102,7 +102,6 @@ src/renderer/ positioning.ts # Facade export for positioning controller positioning/ controller.ts # Position controller orchestration - invisible-layout*.ts # Invisible layer layout computations position-state.ts # Position state helpers handlers/ keyboard.ts # Keybindings, chord handling, modal key routing @@ -125,7 +124,7 @@ src/renderer/ ## Flow Diagram -The main process has three layers: `main.ts` delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough. +The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough. ```mermaid flowchart LR @@ -162,7 +161,7 @@ flowchart LR subgraph Svc["Services — src/core/services/"] Mpv["MPV Stack
transport · protocol
properties · metrics"]:::svc - Overlay["Overlay Manager
window · geometry
visibility · bridge"]:::svc + Overlay["Overlay Manager
window · visibility · bridge"]:::svc Mining["Mining & Subtitles
mining · field-grouping
subtitle-ws · tokenizer"]:::svc Integrations["Integrations
jimaku · subsync
texthooker · yomitan"]:::svc Tracking["Tracking
anilist · jellyfin
immersion · discord"]:::svc @@ -172,9 +171,7 @@ flowchart LR Bridge(["preload.ts
Electron IPC"]):::bridge subgraph Rend["Renderer — src/renderer/"] - Visible["Visible window
Yomitan lookups"]:::rend - Invisible["Invisible window
mpv positioning"]:::rend - Secondary["Secondary window
subtitle bar"]:::rend + Overlay["Main overlay window
primary + secondary subtitles"]:::rend UI["subtitle-render
positioning
handlers · modals"]:::rend end @@ -193,10 +190,8 @@ flowchart LR DiscordExt <-->|"RPC"| Integrations Overlay & Mining --> Bridge - Bridge --> Visible - Bridge --> Invisible - Bridge --> Secondary - Visible & Invisible & Secondary --> UI + Bridge --> Overlay + Overlay --> UI style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5 style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5 @@ -264,9 +259,9 @@ For domains migrated to reducer-style transitions (for example AniList token/que - **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend. - **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks. - **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`. -- **Overlay runtime:** `initializeOverlayRuntime()` creates three overlay windows — **visible** (interactive Yomitan lookups), **invisible** (mpv-matched subtitle positioning), and **secondary** (secondary subtitle bar, top 20% via `splitOverlayGeometryForSecondaryBar`) — then registers global shortcuts and sets initial bounds from the window tracker. +- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering) and registers global shortcuts and bounds tracking via the active window tracker. - **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh. -- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results broadcast to all overlay windows. +- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces. - **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state. ```mermaid @@ -298,14 +293,10 @@ flowchart LR OverlayInit["initializeOverlay
Runtime()"]:::phase - OverlayInit --> VisWin["Visible window
Yomitan lookups"]:::init - OverlayInit --> InvWin["Invisible window
mpv positioning"]:::init - OverlayInit --> SecWin["Secondary window
subtitle bar"]:::init + OverlayInit --> MainWin["Main overlay window
primary + secondary subtitles"]:::init OverlayInit --> Shortcuts["Register global
shortcuts"]:::init - VisWin --> Warmups - InvWin --> Warmups - SecWin --> Warmups + MainWin --> Warmups Shortcuts --> Warmups Warmups["Background
warmups"]:::phase @@ -330,7 +321,7 @@ flowchart LR ExtEvt["Shortcuts · config hot-reload"]:::runtime MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime Route --> Process["SubtitlePipeline
normalize → tokenize → merge"]:::runtime - Process --> Broadcast["Update AppState
broadcast to windows"]:::runtime + Process --> Broadcast["Update AppState
broadcast to renderer + modals"]:::runtime end WarmupGroup --> Loop diff --git a/docs/index.md b/docs/index.md index 335d430..85549aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,8 +38,8 @@ features: - icon: src: /assets/dual-layer.svg alt: Dual layer icon - title: Three-Plane Overlay Stack - details: Secondary context plane + visible interactive layer + invisible interaction plane, each with independent behavior and startup state. + title: Unified Overlay Stack + details: Primary interactive subtitle layer with a built-in secondary context bar, all in one overlay window. - icon: src: /assets/highlight.svg alt: Highlight icon diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md index 0e3f540..25b3b95 100644 --- a/docs/mining-workflow.md +++ b/docs/mining-workflow.md @@ -24,11 +24,11 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization: This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes. -## The Three Overlay Planes +## Overlay Model -SubMiner uses three overlay planes, each serving a different purpose. +SubMiner uses one overlay window with modal surfaces. -### Visible Overlay +### Primary Subtitle Layer The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports: @@ -38,31 +38,17 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w - Modal dialogs for Jimaku search, field grouping, subsync, and runtime options - **N+1 highlighting** — known words from your Anki deck are visually highlighted -Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin). +Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin). -### Secondary Subtitle Plane +### Secondary Subtitle Bar -The secondary plane is a compact top-strip layer for translation and context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden. +The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden. -It is controlled by `secondarySub` configuration and shares lifecycle with the overlay stack. +It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window. -### Invisible Overlay +### Modal Surfaces -The invisible overlay is a transparent layer aligned with mpv's own subtitle rendering. It uses mpv's subtitle metrics (font size, margins, position, scaling) to map click targets accurately. - -This layer still supports: - -- Word-level click-through lookups over the text region -- Optional manual position fine-tuning in pixel mode -- Independent toggle behavior with global shortcuts - -Position edit mode is available via `Ctrl/Cmd+Shift+P`, then arrow keys / `hjkl` to nudge position; `Shift` moves faster. Save with `Enter` or `Ctrl+S`, cancel with `Esc`. - -Toggle controls: - -- `Alt+Shift+O` / `y-t`: visible overlay -- `Alt+Shift+I` / `y-i`: invisible overlay -- Secondary plane visibility is controlled via `secondarySub` config and matching global shortcuts. +Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window. ## Looking Up Words @@ -73,10 +59,10 @@ Toggle controls: 3. Yomitan detects the text selection and opens its popup with dictionary results. 4. From the Yomitan popup, you can add the word directly to Anki. -### On the Invisible Overlay +### On Overlay Subtitles -1. The invisible layer sits over mpv's own subtitle text. -2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text. +1. Subtitles are rendered directly in the overlay. +2. Click on any word in the subtitle. 3. On macOS, word selection happens automatically on hover. 4. Yomitan popup appears for lookup and card creation. diff --git a/docs/plans/2026-02-26-secondary-subtitles-main-overlay.md b/docs/plans/2026-02-26-secondary-subtitles-main-overlay.md new file mode 100644 index 0000000..c749751 --- /dev/null +++ b/docs/plans/2026-02-26-secondary-subtitles-main-overlay.md @@ -0,0 +1,55 @@ +# Secondary Subtitles Main Overlay Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Ensure secondary subtitles render in the unified main overlay window and remove stale secondary-window/layer paths. + +**Architecture:** Keep secondary subtitle DOM in the shared renderer tree, rely on mode classes (`secondary-sub-hidden|visible|hover`) for visibility, and remove obsolete legacy overlay-layer assumptions. Preserve modal behavior and existing subtitle rendering flow. + +**Tech Stack:** TypeScript, Electron renderer CSS/DOM, Bun test runner. + +--- + +### Task 1: Add Regression Tests For Main Overlay Secondary Rendering + +**Files:** +- Modify: `src/renderer/subtitle-render.test.ts` +- Modify: `src/renderer/error-recovery.test.ts` + +**Step 1: Write failing tests** +- Assert stylesheet no longer hides secondary subtitles in `layer-visible`. +- Assert renderer platform resolution ignores legacy `secondary` overlay layer. + +**Step 2: Run tests to verify failures** + +Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts` +Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling. + +### Task 2: Remove Secondary-Window CSS/Routing Assumptions + +**Files:** +- Modify: `src/renderer/style.css` +- Modify: `src/renderer/utils/platform.ts` +- Modify: `src/renderer/error-recovery.ts` +- Modify: `src/types.ts` + +**Step 1: Implement minimal changes** +- Remove legacy forced hide on `#secondarySubContainer`. +- Remove obsolete layer-specific secondary-subtitle CSS blocks. +- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver. +- Narrow related overlay layer type unions. + +**Step 2: Run targeted tests** + +Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts` +Expected: PASS. + +### Task 3: Validate Wider Related Surface + +**Files:** +- No additional code changes required. + +**Step 1: Run broader related tests** + +Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/overlay-window-factory.test.ts src/core/services/overlay-manager.test.ts` +Expected: Renderer tests pass; report any unrelated pre-existing failures. diff --git a/package.json b/package.json index 4f239b8..85c1031 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/positioning/invisible-layout-helpers.test.ts src/renderer/positioning/invisible-layout-metrics.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", - "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/positioning/invisible-layout-helpers.test.js dist/renderer/positioning/invisible-layout-metrics.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", + "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index f26026b..ab03386 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -77,7 +77,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void { if (isObject(src.shortcuts)) { const shortcutKeys = [ 'toggleVisibleOverlayGlobal', - 'toggleInvisibleOverlayGlobal', 'copySubtitle', 'copySubtitleMultiple', 'updateLastCardFromClipboard', @@ -113,24 +112,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void { } } - if (isObject(src.invisibleOverlay)) { - const startupVisibility = src.invisibleOverlay.startupVisibility; - if ( - startupVisibility === 'platform-default' || - startupVisibility === 'visible' || - startupVisibility === 'hidden' - ) { - resolved.invisibleOverlay.startupVisibility = startupVisibility; - } else if (startupVisibility !== undefined) { - warn( - 'invisibleOverlay.startupVisibility', - startupVisibility, - resolved.invisibleOverlay.startupVisibility, - 'Expected platform-default, visible, or hidden.', - ); - } - } - if (isObject(src.secondarySub)) { if (Array.isArray(src.secondarySub.secondarySubLanguages)) { resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 6a159da..46a3194 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -23,7 +23,6 @@ export { export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle'; export { cycleSecondarySubMode } from './subtitle-position'; export { - getInitialInvisibleOverlayVisibility, isAutoUpdateEnabledRuntime, shouldAutoInitializeOverlayRuntimeFromConfig, shouldBindVisibleOverlayToMpvSubVisibility, @@ -59,14 +58,12 @@ export { createOverlayWindow, enforceOverlayLayerOrder, ensureOverlayWindowLevel, + syncOverlayWindowLayer, updateOverlayWindowBounds, } from './overlay-window'; export { initializeOverlayRuntime } from './overlay-runtime-init'; export { - setInvisibleOverlayVisible, setVisibleOverlayVisible, - syncInvisibleOverlayMousePassthrough, - updateInvisibleOverlayVisibility, updateVisibleOverlayVisibility, } from './overlay-visibility'; export { @@ -76,6 +73,7 @@ export { replayCurrentSubtitleRuntime, resolveCurrentAudioStreamIndex, sendMpvCommandRuntime, + setMpvSecondarySubVisibilityRuntime, setMpvSubVisibilityRuntime, showMpvOsdRuntime, } from './mpv'; diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 8298209..bd21078 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -60,6 +60,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'sub-use-margins', 'pause', 'media-title', + 'secondary-sub-visibility', + 'sub-visibility', ]; const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 110685d..38591e3 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -119,6 +119,38 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); }); +test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => { + const { deps, state } = createDeps({ + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => true, + }); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sub-visibility', data: 'yes' }, + deps, + ); + + assert.deepEqual(state.commands.pop(), { + command: ['set_property', 'sub-visibility', 'no'], + }); +}); + +test('dispatchMpvProtocolMessage enforces secondary sub-visibility hidden when overlay suppression is enabled', async () => { + const { deps, state } = createDeps({ + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => true, + }); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sub-visibility', data: 'yes' }, + deps, + ); + + assert.deepEqual(state.commands.pop(), { + command: ['set_property', 'secondary-sub-visibility', 'no'], + }); +}); + test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => { const { deps, state } = createDeps(); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index fae4f00..43c08c2 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -48,6 +48,7 @@ export interface MpvProtocolHandleMessageDeps { }; getSubtitleMetrics: () => MpvSubtitleRenderMetrics; isVisibleOverlayVisible: () => boolean; + shouldBindVisibleOverlayToMpvSubVisibility?: () => boolean; emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; emitSubtitleAssChange: (payload: { text: string }) => void; emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; @@ -216,6 +217,22 @@ export async function dispatchMpvProtocolMessage( deps.emitSubtitleMetricsChange({ subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow), }); + } else if (msg.name === 'sub-visibility') { + if ( + deps.shouldBindVisibleOverlayToMpvSubVisibility?.() && + deps.isVisibleOverlayVisible() && + asBoolean(msg.data, false) + ) { + deps.sendCommand({ command: ['set_property', 'sub-visibility', 'no'] }); + } + } else if (msg.name === 'secondary-sub-visibility') { + if ( + deps.shouldBindVisibleOverlayToMpvSubVisibility?.() && + deps.isVisibleOverlayVisible() && + asBoolean(msg.data, false) + ) { + deps.sendCommand({ command: ['set_property', 'secondary-sub-visibility', 'no'] }); + } } else if (msg.name === 'sub-use-margins') { deps.emitSubtitleMetricsChange({ subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins), diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 0e35064..5c8b95e 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -306,6 +306,32 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re assert.equal(hasPathRequest, true); }); +test('MpvIpcClient connect does not force primary subtitle visibility from binding path', () => { + const commands: unknown[] = []; + const client = new MpvIpcClient( + '/tmp/mpv.sock', + makeDeps({ + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => true, + }), + ); + (client as any).send = (command: unknown) => { + commands.push(command); + return true; + }; + + const callbacks = (client as any).transport.callbacks; + callbacks.onConnect(); + + const hasPrimaryVisibilityMutation = commands.some( + (command) => + Array.isArray((command as { command: unknown[] }).command) && + (command as { command: unknown[] }).command[0] === 'set_property' && + (command as { command: unknown[] }).command[1] === 'sub-visibility', + ); + assert.equal(hasPrimaryVisibilityMutation, false); +}); + test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => { const commands: unknown[] = []; const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 9f40a21..f48a3d8 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -44,6 +44,7 @@ export interface MpvRuntimeClientLike { replayCurrentSubtitle?: () => void; playNextSubtitle?: () => void; setSubVisibility?: (visible: boolean) => void; + setSecondarySubVisibility?: (visible: boolean) => void; } export function showMpvOsdRuntime( @@ -84,6 +85,14 @@ export function setMpvSubVisibilityRuntime( mpvClient.setSubVisibility(visible); } +export function setMpvSecondarySubVisibilityRuntime( + mpvClient: MpvRuntimeClientLike | null, + visible: boolean, +): void { + if (!mpvClient?.setSecondarySubVisibility) return; + mpvClient.setSecondarySubVisibility(visible); +} + export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol'; export interface MpvIpcClientProtocolDeps { @@ -181,8 +190,6 @@ export class MpvIpcClient implements MpvClient { setTimeout(() => { this.deps.setOverlayVisible(true); }, 100); - } else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) { - this.setSubVisibility(!this.deps.isVisibleOverlayVisible()); } this.firstConnection = false; @@ -290,6 +297,8 @@ export class MpvIpcClient implements MpvClient { getResolvedConfig: () => this.deps.getResolvedConfig(), getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics, isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + this.deps.shouldBindVisibleOverlayToMpvSubVisibility(), emitSubtitleChange: (payload) => { this.emit('subtitle-change', payload); }, @@ -488,7 +497,7 @@ export class MpvIpcClient implements MpvClient { this.previousSecondarySubVisibility = null; } - private setSecondarySubVisibility(visible: boolean): void { + setSecondarySubVisibility(visible: boolean): void { this.send({ command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'], }); diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index d969137..39d3e4f 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -9,11 +9,8 @@ import { test('overlay manager initializes with empty windows and hidden overlays', () => { const manager = createOverlayManager(); assert.equal(manager.getMainWindow(), null); - assert.equal(manager.getInvisibleWindow(), null); - assert.equal(manager.getSecondaryWindow(), null); assert.equal(manager.getModalWindow(), null); assert.equal(manager.getVisibleOverlayVisible(), false); - assert.equal(manager.getInvisibleOverlayVisible(), false); assert.deepEqual(manager.getOverlayWindows(), []); }); @@ -22,28 +19,17 @@ test('overlay manager stores window references and returns stable window order', const visibleWindow = { isDestroyed: () => false, } as unknown as Electron.BrowserWindow; - const invisibleWindow = { - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow; - const secondaryWindow = { - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow; const modalWindow = { isDestroyed: () => false, } as unknown as Electron.BrowserWindow; manager.setMainWindow(visibleWindow); - manager.setInvisibleWindow(invisibleWindow); - manager.setSecondaryWindow(secondaryWindow); manager.setModalWindow(modalWindow); assert.equal(manager.getMainWindow(), visibleWindow); - assert.equal(manager.getInvisibleWindow(), invisibleWindow); - assert.equal(manager.getSecondaryWindow(), secondaryWindow); assert.equal(manager.getModalWindow(), modalWindow); - assert.equal(manager.getOverlayWindow('visible'), visibleWindow); - assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow); - assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]); + assert.equal(manager.getOverlayWindow(), visibleWindow); + assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]); }); test('overlay manager excludes destroyed windows', () => { @@ -51,26 +37,18 @@ test('overlay manager excludes destroyed windows', () => { manager.setMainWindow({ isDestroyed: () => true, } as unknown as Electron.BrowserWindow); - manager.setInvisibleWindow({ - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow); - manager.setSecondaryWindow({ - isDestroyed: () => true, - } as unknown as Electron.BrowserWindow); manager.setModalWindow({ isDestroyed: () => false, } as unknown as Electron.BrowserWindow); - assert.equal(manager.getOverlayWindows().length, 1); + assert.equal(manager.getOverlayWindows().length, 0); }); test('overlay manager stores visibility state', () => { const manager = createOverlayManager(); manager.setVisibleOverlayVisible(true); - manager.setInvisibleOverlayVisible(true); assert.equal(manager.getVisibleOverlayVisible(), true); - assert.equal(manager.getInvisibleOverlayVisible(), true); }); test('overlay manager broadcasts to non-destroyed windows', () => { @@ -84,58 +62,25 @@ test('overlay manager broadcasts to non-destroyed windows', () => { }, }, } as unknown as Electron.BrowserWindow; - const deadWindow = { - isDestroyed: () => true, - webContents: { - send: (..._args: unknown[]) => {}, - }, - } as unknown as Electron.BrowserWindow; - const secondaryWindow = { - isDestroyed: () => false, - webContents: { - send: (...args: unknown[]) => { - calls.push(args); - }, - }, - } as unknown as Electron.BrowserWindow; - manager.setMainWindow(aliveWindow); - manager.setInvisibleWindow(deadWindow); - manager.setSecondaryWindow(secondaryWindow); manager.setModalWindow({ isDestroyed: () => false, webContents: { send: () => {} }, } as unknown as Electron.BrowserWindow); manager.broadcastToOverlayWindows('x', 1, 'a'); - assert.deepEqual(calls, [ - ['x', 1, 'a'], - ['x', 1, 'a'], - ]); + assert.deepEqual(calls, [['x', 1, 'a']]); }); -test('overlay manager applies bounds by layer', () => { +test('overlay manager applies bounds for main and modal windows', () => { const manager = createOverlayManager(); const visibleCalls: Electron.Rectangle[] = []; - const invisibleCalls: Electron.Rectangle[] = []; const visibleWindow = { isDestroyed: () => false, setBounds: (bounds: Electron.Rectangle) => { visibleCalls.push(bounds); }, } as unknown as Electron.BrowserWindow; - const invisibleWindow = { - isDestroyed: () => false, - setBounds: (bounds: Electron.Rectangle) => { - invisibleCalls.push(bounds); - }, - } as unknown as Electron.BrowserWindow; - const secondaryWindow = { - isDestroyed: () => false, - setBounds: (bounds: Electron.Rectangle) => { - invisibleCalls.push(bounds); - }, - } as unknown as Electron.BrowserWindow; const modalCalls: Electron.Rectangle[] = []; const modalWindow = { isDestroyed: () => false, @@ -144,28 +89,14 @@ test('overlay manager applies bounds by layer', () => { }, } as unknown as Electron.BrowserWindow; manager.setMainWindow(visibleWindow); - manager.setInvisibleWindow(invisibleWindow); - manager.setSecondaryWindow(secondaryWindow); manager.setModalWindow(modalWindow); - manager.setOverlayWindowBounds('visible', { + manager.setOverlayWindowBounds({ x: 10, y: 20, width: 30, height: 40, }); - manager.setOverlayWindowBounds('invisible', { - x: 1, - y: 2, - width: 3, - height: 4, - }); - manager.setSecondaryWindowBounds({ - x: 8, - y: 9, - width: 10, - height: 11, - }); manager.setModalWindowBounds({ x: 80, y: 90, @@ -174,14 +105,10 @@ test('overlay manager applies bounds by layer', () => { }); assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]); - assert.deepEqual(invisibleCalls, [ - { x: 1, y: 2, width: 3, height: 4 }, - { x: 8, y: 9, width: 10, height: 11 }, - ]); assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]); }); -test('runtime-option and debug broadcasts use expected channels', () => { +test('runtime-option broadcast still uses expected channel', () => { const broadcasts: unknown[][] = []; broadcastRuntimeOptionsChangedRuntime( () => [], @@ -196,14 +123,8 @@ test('runtime-option and debug broadcasts use expected channels', () => { (enabled) => { state = enabled; }, - (channel, ...args) => { - broadcasts.push([channel, ...args]); - }, ); assert.equal(changed, true); assert.equal(state, true); - assert.deepEqual(broadcasts, [ - ['runtime-options:changed', []], - ['overlay-debug-visualization:set', true], - ]); + assert.deepEqual(broadcasts, [['runtime-options:changed', []]]); }); diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts index 942d771..942c81b 100644 --- a/src/core/services/overlay-manager.ts +++ b/src/core/services/overlay-manager.ts @@ -2,60 +2,37 @@ import type { BrowserWindow } from 'electron'; import { RuntimeOptionState, WindowGeometry } from '../../types'; import { updateOverlayWindowBounds } from './overlay-window'; -type OverlayLayer = 'visible' | 'invisible'; - export interface OverlayManager { getMainWindow: () => BrowserWindow | null; setMainWindow: (window: BrowserWindow | null) => void; - getInvisibleWindow: () => BrowserWindow | null; - setInvisibleWindow: (window: BrowserWindow | null) => void; - getSecondaryWindow: () => BrowserWindow | null; - setSecondaryWindow: (window: BrowserWindow | null) => void; getModalWindow: () => BrowserWindow | null; setModalWindow: (window: BrowserWindow | null) => void; - getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; - setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; - setSecondaryWindowBounds: (geometry: WindowGeometry) => void; + getOverlayWindow: () => BrowserWindow | null; + setOverlayWindowBounds: (geometry: WindowGeometry) => void; setModalWindowBounds: (geometry: WindowGeometry) => void; getVisibleOverlayVisible: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; - getInvisibleOverlayVisible: () => boolean; - setInvisibleOverlayVisible: (visible: boolean) => void; getOverlayWindows: () => BrowserWindow[]; broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; } export function createOverlayManager(): OverlayManager { let mainWindow: BrowserWindow | null = null; - let invisibleWindow: BrowserWindow | null = null; - let secondaryWindow: BrowserWindow | null = null; let modalWindow: BrowserWindow | null = null; let visibleOverlayVisible = false; - let invisibleOverlayVisible = false; return { getMainWindow: () => mainWindow, setMainWindow: (window) => { mainWindow = window; }, - getInvisibleWindow: () => invisibleWindow, - setInvisibleWindow: (window) => { - invisibleWindow = window; - }, - getSecondaryWindow: () => secondaryWindow, - setSecondaryWindow: (window) => { - secondaryWindow = window; - }, getModalWindow: () => modalWindow, setModalWindow: (window) => { modalWindow = window; }, - getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow), - setOverlayWindowBounds: (layer, geometry) => { - updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow); - }, - setSecondaryWindowBounds: (geometry) => { - updateOverlayWindowBounds(geometry, secondaryWindow); + getOverlayWindow: () => mainWindow, + setOverlayWindowBounds: (geometry) => { + updateOverlayWindowBounds(geometry, mainWindow); }, setModalWindowBounds: (geometry) => { updateOverlayWindowBounds(geometry, modalWindow); @@ -64,36 +41,12 @@ export function createOverlayManager(): OverlayManager { setVisibleOverlayVisible: (visible) => { visibleOverlayVisible = visible; }, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, - setInvisibleOverlayVisible: (visible) => { - invisibleOverlayVisible = visible; - }, getOverlayWindows: () => { - const windows: BrowserWindow[] = []; - if (mainWindow && !mainWindow.isDestroyed()) { - windows.push(mainWindow); - } - if (invisibleWindow && !invisibleWindow.isDestroyed()) { - windows.push(invisibleWindow); - } - if (secondaryWindow && !secondaryWindow.isDestroyed()) { - windows.push(secondaryWindow); - } - return windows; + return mainWindow && !mainWindow.isDestroyed() ? [mainWindow] : []; }, broadcastToOverlayWindows: (channel, ...args) => { - const windows: BrowserWindow[] = []; if (mainWindow && !mainWindow.isDestroyed()) { - windows.push(mainWindow); - } - if (invisibleWindow && !invisibleWindow.isDestroyed()) { - windows.push(invisibleWindow); - } - if (secondaryWindow && !secondaryWindow.isDestroyed()) { - windows.push(secondaryWindow); - } - for (const window of windows) { - window.webContents.send(channel, ...args); + mainWindow.webContents.send(channel, ...args); } }, }; @@ -110,10 +63,8 @@ export function setOverlayDebugVisualizationEnabledRuntime( currentEnabled: boolean, nextEnabled: boolean, setState: (enabled: boolean) => void, - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, ): boolean { if (currentEnabled === nextEnabled) return false; setState(nextEnabled); - broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled); return true; } diff --git a/src/core/services/overlay-window-geometry.ts b/src/core/services/overlay-window-geometry.ts deleted file mode 100644 index 328c186..0000000 --- a/src/core/services/overlay-window-geometry.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { WindowGeometry } from '../../types'; - -export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2; - -function toInteger(value: number): number { - return Number.isFinite(value) ? Math.round(value) : 0; -} - -function clampPositive(value: number): number { - return Math.max(1, toInteger(value)); -} - -export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): { - secondary: WindowGeometry; - primary: WindowGeometry; -} { - const x = toInteger(geometry.x); - const y = toInteger(geometry.y); - const width = clampPositive(geometry.width); - const totalHeight = clampPositive(geometry.height); - - const secondaryHeight = clampPositive( - Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)), - ); - const primaryHeight = clampPositive(totalHeight - secondaryHeight); - - return { - secondary: { - x, - y, - width, - height: secondaryHeight, - }, - primary: { - x, - y: y + secondaryHeight, - width, - height: primaryHeight, - }, - }; -} diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts deleted file mode 100644 index 31d68af..0000000 --- a/src/core/services/overlay-window.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry'; - -test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => { - const regions = splitOverlayGeometryForSecondaryBar({ - x: 100, - y: 50, - width: 1200, - height: 900, - }); - - assert.deepEqual(regions.secondary, { - x: 100, - y: 50, - width: 1200, - height: 180, - }); - assert.deepEqual(regions.primary, { - x: 100, - y: 230, - width: 1200, - height: 720, - }); -}); - -test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => { - const regions = splitOverlayGeometryForSecondaryBar({ - x: 0, - y: 0, - width: 300, - height: 1, - }); - - assert.ok(regions.secondary.height >= 1); - assert.ok(regions.primary.height >= 1); -}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 1bb1762..fdd1e36 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -4,8 +4,25 @@ import { WindowGeometry } from '../../types'; import { createLogger } from '../../logger'; const logger = createLogger('main:overlay-window'); +const overlayWindowLayerByInstance = new WeakMap(); -export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal'; +function getOverlayWindowHtmlPath(): string { + return path.join(__dirname, '..', '..', 'renderer', 'index.html'); +} + +function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind): void { + overlayWindowLayerByInstance.set(window, layer); + const htmlPath = getOverlayWindowHtmlPath(); + window + .loadFile(htmlPath, { + query: { layer }, + }) + .catch((err) => { + logger.error('Failed to load HTML file:', err); + }); +} + +export type OverlayWindowKind = 'visible' | 'modal'; export function updateOverlayWindowBounds( geometry: WindowGeometry, @@ -32,14 +49,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void { export function enforceOverlayLayerOrder(options: { visibleOverlayVisible: boolean; - invisibleOverlayVisible: boolean; mainWindow: BrowserWindow | null; - invisibleWindow: BrowserWindow | null; ensureOverlayWindowLevel: (window: BrowserWindow) => void; }): void { - if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return; + if (!options.visibleOverlayVisible) return; if (!options.mainWindow || options.mainWindow.isDestroyed()) return; - if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return; options.ensureOverlayWindowLevel(options.mainWindow); options.mainWindow.moveTop(); @@ -49,7 +63,6 @@ export function createOverlayWindow( kind: OverlayWindowKind, options: { isDev: boolean; - overlayDebugVisualizationEnabled: boolean; ensureOverlayWindowLevel: (window: BrowserWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; @@ -83,16 +96,7 @@ export function createOverlayWindow( }); options.ensureOverlayWindowLevel(window); - - const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html'); - - window - .loadFile(htmlPath, { - query: { layer: kind }, - }) - .catch((err) => { - logger.error('Failed to load HTML file:', err); - }); + loadOverlayWindowLayer(window, kind); window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { logger.error('Page failed to load:', errorCode, errorDescription, validatedURL); @@ -100,10 +104,6 @@ export function createOverlayWindow( window.webContents.on('did-finish-load', () => { options.onRuntimeOptionsChanged(); - window.webContents.send( - 'overlay-debug-visualization:set', - options.overlayDebugVisualizationEnabled, - ); }); if (kind === 'visible') { @@ -140,3 +140,9 @@ export function createOverlayWindow( return window; } + +export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): void { + if (window.isDestroyed()) return; + if (overlayWindowLayerByInstance.get(window) === layer) return; + loadOverlayWindowLayer(window, layer); +} diff --git a/src/main.ts b/src/main.ts index fbb6d4c..05f703f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -218,7 +218,6 @@ import { createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, createBuildEnforceOverlayLayerOrderMainDepsHandler, createBuildEnsureOverlayWindowLevelMainDepsHandler, - createBuildUpdateInvisibleOverlayBoundsMainDepsHandler, createBuildUpdateVisibleOverlayBoundsMainDepsHandler, createOverlayWindowRuntimeHandlers, createOverlayRuntimeBootstrapHandlers, @@ -234,7 +233,6 @@ import { createSetOverlayDebugVisualizationEnabledHandler, createEnforceOverlayLayerOrderHandler, createEnsureOverlayWindowLevelHandler, - createUpdateInvisibleOverlayBoundsHandler, createUpdateVisibleOverlayBoundsHandler, createLoadSubtitlePositionHandler, createSaveSubtitlePositionHandler, @@ -356,16 +354,16 @@ import { runStartupBootstrapRuntime, saveSubtitlePosition as saveSubtitlePositionCore, sendMpvCommandRuntime, - setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore, + setMpvSecondarySubVisibilityRuntime, setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, + syncOverlayWindowLayer, setVisibleOverlayVisible as setVisibleOverlayVisibleCore, showMpvOsdRuntime, tokenizeSubtitle as tokenizeSubtitleCore, triggerFieldGrouping as triggerFieldGroupingCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore, } from './core/services'; -import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { guessAnilistMediaInfo, @@ -376,7 +374,10 @@ import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createMainRuntimeRegistry } from './main/runtime/registry'; -import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight'; +import { + createEnsureOverlayMpvSubtitlesHiddenHandler, + createRestoreOverlayMpvSubtitlesHandler, +} from './main/runtime/overlay-mpv-sub-visibility'; import { composeAnilistSetupHandlers, composeAnilistTrackingHandlers, @@ -644,7 +645,6 @@ const buildOverlayContentMeasurementStoreMainDepsHandler = }); const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ getMainWindow: () => overlayManager.getMainWindow(), - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getModalWindow: () => overlayManager.getModalWindow(), createModalWindow: () => createModalWindow(), getModalGeometry: () => getCurrentOverlayGeometry(), @@ -725,13 +725,70 @@ async function initializeDiscordPresenceService(): Promise { await appState.discordPresenceService.start(); publishDiscordPresence(); } -const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({ +const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({ getMpvClient: () => appState.mpvClient, - getCurrentSubtitleData: () => appState.currentSubtitleData, - getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex, - getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision, - getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null, + getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility, + setSavedSubVisibility: (visible) => { + appState.overlaySavedMpvSubVisibility = visible; + }, + getSavedSecondarySubVisibility: () => appState.overlaySavedSecondaryMpvSubVisibility, + setSavedSecondarySubVisibility: (visible) => { + appState.overlaySavedSecondaryMpvSubVisibility = visible; + }, + getRevision: () => appState.overlayMpvSubVisibilityRevision, + setRevision: (revision) => { + appState.overlayMpvSubVisibilityRevision = revision; + }, + setMpvSubVisibility: (visible) => { + setMpvSubVisibilityRuntime(appState.mpvClient, visible); + }, + setMpvSecondarySubVisibility: (visible) => { + setMpvSecondarySubVisibilityRuntime(appState.mpvClient, visible); + }, + logWarn: (message, error) => { + logger.warn(message, error); + }, }); +const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({ + getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility, + setSavedSubVisibility: (visible) => { + appState.overlaySavedMpvSubVisibility = visible; + }, + getSavedSecondarySubVisibility: () => appState.overlaySavedSecondaryMpvSubVisibility, + setSavedSecondarySubVisibility: (visible) => { + appState.overlaySavedSecondaryMpvSubVisibility = visible; + }, + getRevision: () => appState.overlayMpvSubVisibilityRevision, + setRevision: (revision) => { + appState.overlayMpvSubVisibilityRevision = revision; + }, + isMpvConnected: () => Boolean(appState.mpvClient?.connected), + shouldKeepSuppressedFromVisibleOverlayBinding: () => shouldSuppressMpvSubtitlesForOverlay(), + setMpvSubVisibility: (visible) => { + setMpvSubVisibilityRuntime(appState.mpvClient, visible); + }, + setMpvSecondarySubVisibility: (visible) => { + setMpvSecondarySubVisibilityRuntime(appState.mpvClient, visible); + }, +}); + +function shouldSuppressMpvSubtitlesForOverlay(): boolean { + return ( + appState.secondarySubMode === 'visible' || + (overlayManager.getVisibleOverlayVisible() && + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility()) + ); +} + +function syncOverlayMpvSubtitleSuppression(): void { + if (shouldSuppressMpvSubtitlesForOverlay()) { + void ensureOverlayMpvSubtitlesHidden(); + return; + } + + restoreOverlayMpvSubtitles(); +} + const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({ getResolvedConfig: () => getResolvedConfig(), defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, @@ -766,7 +823,6 @@ const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMa const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({ getResolvedConfig: () => getResolvedConfig(), getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - platform: process.platform, defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl, @@ -801,15 +857,7 @@ const buildSubtitleProcessingControllerMainDepsHandler = return await tokenizeSubtitle(text); }, emitSubtitle: (payload) => { - const previousSubtitleText = appState.currentSubtitleData?.text ?? null; - const nextSubtitleText = payload?.text ?? null; - const subtitleChanged = previousSubtitleText !== nextSubtitleText; appState.currentSubtitleData = payload; - if (subtitleChanged) { - appState.hoveredSubtitleTokenIndex = null; - appState.hoveredSubtitleRevision += 1; - applyHoveredTokenOverlay(); - } broadcastToOverlayWindows('subtitle:set', payload); subtitleWsService.broadcast(payload, { enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, @@ -850,7 +898,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( copySubtitle: () => { copyCurrentSubtitle(); }, - toggleSecondarySubMode: () => cycleSecondarySubMode(), + toggleSecondarySubMode: () => handleCycleSecondarySubMode(), updateLastCardFromClipboard: () => updateLastCardFromClipboard(), triggerFieldGrouping: () => triggerFieldGrouping(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), @@ -899,8 +947,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp refreshGlobalAndOverlayShortcuts(); }, setSecondarySubMode: (mode) => { - appState.secondarySubMode = mode; - syncSecondaryOverlayWindowVisibility(); + setSecondarySubMode(mode); }, broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows(channel, payload); @@ -1023,9 +1070,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime({ getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), getResolver: () => getFieldGroupingResolver(), setResolver: (resolver) => setFieldGroupingResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => @@ -1067,26 +1112,40 @@ const mediaRuntime = createMediaRuntimeService( const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( createBuildOverlayVisibilityRuntimeMainDepsHandler({ getMainWindow: () => overlayManager.getMainWindow(), - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), getWindowTracker: () => appState.windowTracker, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; }, updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), - updateInvisibleOverlayBounds: (geometry: WindowGeometry) => - updateInvisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel(window); }, + syncPrimaryOverlayWindowLayer: (layer) => { + syncPrimaryOverlayWindowLayer(layer); + }, enforceOverlayLayerOrder: () => { enforceOverlayLayerOrder(); }, syncOverlayShortcuts: () => { overlayShortcutsRuntime.syncOverlayShortcuts(); }, + isMacOSPlatform: () => process.platform === 'darwin', + showOverlayLoadingOsd: (message: string) => { + showMpvOsd(message); + }, + resolveFallbackBounds: () => { + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + return { + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }; + }, })(), ); @@ -1165,7 +1224,6 @@ const buildSetOverlayDebugVisualizationEnabledMainDepsHandler = setCurrentEnabled: (next) => { appState.overlayDebugVisualizationEnabled = next; }, - broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), }); const setOverlayDebugVisualizationEnabledMainDeps = buildSetOverlayDebugVisualizationEnabledMainDepsHandler(); @@ -1826,6 +1884,9 @@ const { destroyTray: () => destroyTray(), stopConfigHotReload: () => configHotReloadRuntime.stop(), restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), + restoreMpvSubVisibilityForInvisibleOverlay: () => { + restoreOverlayMpvSubtitles({ respectVisibleOverlayBinding: false }); + }, unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), stopSubtitleWebsocket: () => subtitleWsService.stop(), stopTexthookerService: () => texthookerService.stop(), @@ -1870,14 +1931,11 @@ const { createMainWindow: () => { createMainWindow(); }, - createInvisibleWindow: () => { - createInvisibleWindow(); - }, updateVisibleOverlayVisibility: () => { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }, - updateInvisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); + syncOverlayMpvSubtitleSuppression: () => { + syncOverlayMpvSubtitleSuppression(); }, }, }); @@ -1934,8 +1992,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR ); }, setSecondarySubMode: (mode: SecondarySubMode) => { - appState.secondarySubMode = mode; - syncSecondaryOverlayWindowVisibility(); + setSecondarySubMode(mode); }, defaultSecondarySubMode: 'hover', defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, @@ -2124,6 +2181,9 @@ const { updateCurrentMediaPath: (path) => { mediaRuntime.updateCurrentMediaPath(path); }, + restoreMpvSubVisibilityForInvisibleOverlay: () => { + restoreOverlayMpvSubtitles({ respectVisibleOverlayBinding: false }); + }, getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey) => { resetAnilistMediaTracking(mediaKey); @@ -2149,6 +2209,9 @@ const { updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); }, + syncOverlayMpvSubtitleSuppression: () => { + syncOverlayMpvSubtitleSuppression(); + }, }, mpvClientRuntimeServiceFactoryMainDeps: { createClient: MpvIpcClient, @@ -2170,8 +2233,8 @@ const { appState.mpvSubtitleRenderMetrics = metrics; }, applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), - broadcastMetrics: (metrics) => { - broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics); + broadcastMetrics: () => { + // no renderer consumer for subtitle render metrics updates at present }, }, tokenizer: { @@ -2276,52 +2339,21 @@ function getCurrentOverlayGeometry(): WindowGeometry { return getOverlayGeometryFallback(); } -function syncSecondaryOverlayWindowVisibility(): void { - const secondaryWindow = overlayManager.getSecondaryWindow(); - if (!secondaryWindow || secondaryWindow.isDestroyed()) return; - - if (appState.secondarySubMode === 'hidden') { - secondaryWindow.setIgnoreMouseEvents(true, { forward: true }); - secondaryWindow.hide(); - return; - } - - secondaryWindow.setIgnoreMouseEvents(false); - ensureOverlayWindowLevel(secondaryWindow); - if (typeof secondaryWindow.showInactive === 'function') { - secondaryWindow.showInactive(); - } else { - secondaryWindow.show(); - } -} - -function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeometry): void { +function applyOverlayRegions(geometry: WindowGeometry): void { lastOverlayWindowGeometry = geometry; - const regions = splitOverlayGeometryForSecondaryBar(geometry); - overlayManager.setOverlayWindowBounds(layer, regions.primary); - overlayManager.setSecondaryWindowBounds(regions.secondary); + overlayManager.setOverlayWindowBounds(geometry); overlayManager.setModalWindowBounds(geometry); - syncSecondaryOverlayWindowVisibility(); } const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ - setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), + setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), }); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( updateVisibleOverlayBoundsMainDeps, ); -const buildUpdateInvisibleOverlayBoundsMainDepsHandler = - createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({ - setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), - }); -const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler(); -const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler( - updateInvisibleOverlayBoundsMainDeps, -); - const buildEnsureOverlayWindowLevelMainDepsHandler = createBuildEnsureOverlayWindowLevelMainDepsHandler({ ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), @@ -2331,21 +2363,23 @@ const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( ensureOverlayWindowLevelMainDeps, ); +function syncPrimaryOverlayWindowLayer(layer: 'visible'): void { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + syncOverlayWindowLayer(mainWindow, layer); +} + const buildEnforceOverlayLayerOrderMainDepsHandler = createBuildEnforceOverlayLayerOrderMainDepsHandler({ enforceOverlayLayerOrderCore: (params) => enforceOverlayLayerOrderCore({ visibleOverlayVisible: params.visibleOverlayVisible, - invisibleOverlayVisible: params.invisibleOverlayVisible, mainWindow: params.mainWindow as BrowserWindow | null, - invisibleWindow: params.invisibleWindow as BrowserWindow | null, ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as BrowserWindow), }), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), getMainWindow: () => overlayManager.getMainWindow(), - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), }); const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); @@ -2361,7 +2395,7 @@ async function ensureYomitanExtensionLoaded(): Promise { return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); } -function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow { +function createOverlayWindow(kind: 'visible' | 'modal'): BrowserWindow { return createOverlayWindowHandler(kind); } @@ -2375,25 +2409,9 @@ function createModalWindow(): BrowserWindow { return window; } -function createSecondaryWindow(): BrowserWindow { - const existingWindow = overlayManager.getSecondaryWindow(); - if (existingWindow && !existingWindow.isDestroyed()) { - return existingWindow; - } - const window = createSecondaryWindowHandler(); - applyOverlayRegions('visible', getCurrentOverlayGeometry()); - return window; -} - function createMainWindow(): BrowserWindow { - const window = createMainWindowHandler(); - createSecondaryWindow(); - return window; + return createMainWindowHandler(); } -function createInvisibleWindow(): BrowserWindow { - return createInvisibleWindowHandler(); -} - function resolveTrayIconPath(): string | null { return resolveTrayIconPathHandler(); } @@ -2412,6 +2430,7 @@ function destroyTray(): void { function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); + syncOverlayMpvSubtitleSuppression(); } function openYomitanSettings(): void { @@ -2441,7 +2460,6 @@ const { getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), registerGlobalShortcutsCore, toggleVisibleOverlay: () => toggleVisibleOverlay(), - toggleInvisibleOverlay: () => toggleInvisibleOverlay(), openYomitanSettings: () => openYomitanSettings(), isDev, getMainWindow: () => overlayManager.getMainWindow(), @@ -2495,8 +2513,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ cycleSecondarySubModeMainDeps: { getSecondarySubMode: () => appState.secondarySubMode, setSecondarySubMode: (mode: SecondarySubMode) => { - appState.secondarySubMode = mode; - syncSecondaryOverlayWindowVisibility(); + setSecondarySubMode(mode); }, getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, setLastSecondarySubToggleAtMs: (timestampMs: number) => { @@ -2510,6 +2527,15 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), }); +function setSecondarySubMode(mode: SecondarySubMode): void { + appState.secondarySubMode = mode; + syncOverlayMpvSubtitleSuppression(); +} + +function handleCycleSecondarySubMode(): void { + cycleSecondarySubMode(); +} + async function triggerSubsyncFromConfig(): Promise { await subsyncRuntime.triggerFromConfig(); } @@ -2613,9 +2639,7 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler( ); const { setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, - setInvisibleOverlayVisible: setInvisibleOverlayVisibleHandler, toggleVisibleOverlay: toggleVisibleOverlayHandler, - toggleInvisibleOverlay: toggleInvisibleOverlayHandler, setOverlayVisible: setOverlayVisibleHandler, toggleOverlay: toggleOverlayHandler, } = createOverlayVisibilityRuntime({ @@ -2625,29 +2649,8 @@ const { overlayManager.setVisibleOverlayVisible(nextVisible); }, updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - updateInvisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), - shouldBindVisibleOverlayToMpvSubVisibility: () => - configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), - isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), - setMpvSubVisibility: (mpvSubVisible) => { - setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible); - }, - }, - setInvisibleOverlayVisibleDeps: { - setInvisibleOverlayVisibleCore, - setInvisibleOverlayVisibleState: (nextVisible) => { - overlayManager.setInvisibleOverlayVisible(nextVisible); - }, - updateInvisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), }, getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), }); const buildHandleOverlayModalClosedMainDepsHandler = @@ -2707,10 +2710,8 @@ const { showMpvOsd: (text: string) => showMpvOsd(text), }, mainDeps: { - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), focusMainWindow: () => { const mainWindow = overlayManager.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; @@ -2721,13 +2722,15 @@ const { onOverlayModalClosed: (modal) => { handleOverlayModalClosed(modal); }, + onOverlayModalOpened: (modal) => { + overlayModalRuntime.notifyOverlayModalOpened(modal); + }, openYomitanSettings: () => openYomitanSettings(), quitApp: () => app.quit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleAss: () => appState.currentSubAssText, - getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, getSubtitlePosition: () => loadSubtitlePosition(), getSubtitleStyle: () => { const resolvedConfig = getResolvedConfig(); @@ -2744,9 +2747,6 @@ const { reportOverlayContentBounds: (payload: unknown) => { overlayContentMeasurementStore.report(payload); }, - reportHoveredSubtitleToken: (tokenIndex: number | null) => { - reportHoveredSubtitleToken(tokenIndex); - }, getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), clearAnilistToken: () => anilistStateRuntime.clearTokenState(), openAnilistSetup: () => openAnilistSetupWindow(), @@ -2800,9 +2800,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ showMpvOsd: (text: string) => showMpvOsd(text), initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), - toggleInvisibleOverlay: () => toggleInvisibleOverlay(), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible), copyCurrentSubtitle: () => copyCurrentSubtitle(), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), mineSentenceCard: () => mineSentenceCard(), @@ -2821,7 +2819,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => cycleSecondarySubMode(), + cycleSecondarySubMode: () => handleCycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), stopApp: () => app.quit(), @@ -2835,40 +2833,29 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ const { createOverlayWindow: createOverlayWindowHandler, createMainWindow: createMainWindowHandler, - createInvisibleWindow: createInvisibleWindowHandler, - createSecondaryWindow: createSecondaryWindowHandler, createModalWindow: createModalWindowHandler, } = createOverlayWindowRuntimeHandlers({ createOverlayWindowDeps: { createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), isDev, - getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled, ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), isOverlayVisible: (windowKind) => windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() - : windowKind === 'invisible' - ? overlayManager.getInvisibleOverlayVisible() - : false, + : false, tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { if (windowKind === 'visible') { overlayManager.setMainWindow(null); - } else if (windowKind === 'invisible') { - overlayManager.setInvisibleWindow(null); - } else if (windowKind === 'secondary') { - overlayManager.setSecondaryWindow(null); } else { overlayManager.setModalWindow(null); } }, }, setMainWindow: (window) => overlayManager.setMainWindow(window), - setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), - setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window), setModalWindow: (window) => overlayManager.setModalWindow(window), }); const { @@ -2948,24 +2935,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = appState, overlayManager: { getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), }, overlayVisibilityRuntime: { updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - updateInvisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), }, overlayShortcutsRuntime: { syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), }, - getInitialInvisibleOverlayVisibility: () => - configDerivedRuntime.getInitialInvisibleOverlayVisibility(), createMainWindow: () => createMainWindow(), - createInvisibleWindow: () => createInvisibleWindow(), registerGlobalShortcuts: () => registerGlobalShortcuts(), - updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), - updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry), + updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), getOverlayWindows: () => getOverlayWindows(), getResolvedConfig: () => getResolvedConfig(), showDesktopNotification, @@ -2975,9 +2955,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = initializeOverlayRuntimeBootstrapDeps: { isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, initializeOverlayRuntimeCore, - setInvisibleOverlayVisible: (visible) => { - overlayManager.setInvisibleOverlayVisible(visible); - }, setOverlayRuntimeInitialized: (initialized) => { appState.overlayRuntimeInitialized = initialized; }, @@ -3035,39 +3012,26 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void { if (!mainWindow || mainWindow.isDestroyed()) { createMainWindow(); } - - const invisibleWindow = overlayManager.getInvisibleWindow(); - if (!invisibleWindow || invisibleWindow.isDestroyed()) { - createInvisibleWindow(); - } } function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); setVisibleOverlayVisibleHandler(visible); -} - -function setInvisibleOverlayVisible(visible: boolean): void { - ensureOverlayWindowsReadyForVisibilityActions(); - setInvisibleOverlayVisibleHandler(visible); - if (visible) { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - } + syncOverlayMpvSubtitleSuppression(); } function toggleVisibleOverlay(): void { ensureOverlayWindowsReadyForVisibilityActions(); toggleVisibleOverlayHandler(); -} -function toggleInvisibleOverlay(): void { - ensureOverlayWindowsReadyForVisibilityActions(); - toggleInvisibleOverlayHandler(); + syncOverlayMpvSubtitleSuppression(); } function setOverlayVisible(visible: boolean): void { setOverlayVisibleHandler(visible); + syncOverlayMpvSubtitleSuppression(); } function toggleOverlay(): void { toggleOverlayHandler(); + syncOverlayMpvSubtitleSuppression(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { handleOverlayModalClosedHandler(modal); @@ -3077,11 +3041,6 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void { handleMpvCommandFromIpcHandler(command); } -function reportHoveredSubtitleToken(tokenIndex: number | null): void { - appState.hoveredSubtitleTokenIndex = tokenIndex; - applyHoveredTokenOverlay(); -} - async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise { return runSubsyncManualFromIpcHandler(request) as Promise; } diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index a4754f6..a9da703 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -12,6 +12,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => { destroyTray: () => calls.push('destroy-tray'), stopConfigHotReload: () => calls.push('stop-config'), restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), + restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), stopSubtitleWebsocket: () => calls.push('stop-ws'), stopTexthookerService: () => calls.push('stop-texthooker'), @@ -33,7 +34,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => { }); cleanup(); - assert.equal(calls.length, 21); + assert.equal(calls.length, 22); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); @@ -58,11 +59,10 @@ test('restore windows on activate recreates windows then syncs visibility', () = const calls: string[] = []; const restore = createRestoreWindowsOnActivateHandler({ createMainWindow: () => calls.push('main'), - createInvisibleWindow: () => calls.push('invisible'), updateVisibleOverlayVisibility: () => calls.push('visible-sync'), - updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'), + syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'), }); restore(); - assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']); + assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']); }); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 62f9d3e..aca18e0 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -2,6 +2,7 @@ export function createOnWillQuitCleanupHandler(deps: { destroyTray: () => void; stopConfigHotReload: () => void; restorePreviousSecondarySubVisibility: () => void; + restoreMpvSubVisibilityForInvisibleOverlay: () => void; unregisterAllGlobalShortcuts: () => void; stopSubtitleWebsocket: () => void; stopTexthookerService: () => void; @@ -25,6 +26,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.destroyTray(); deps.stopConfigHotReload(); deps.restorePreviousSecondarySubVisibility(); + deps.restoreMpvSubVisibilityForInvisibleOverlay(); deps.unregisterAllGlobalShortcuts(); deps.stopSubtitleWebsocket(); deps.stopTexthookerService(); @@ -55,14 +57,12 @@ export function createShouldRestoreWindowsOnActivateHandler(deps: { export function createRestoreWindowsOnActivateHandler(deps: { createMainWindow: () => void; - createInvisibleWindow: () => void; updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; + syncOverlayMpvSubtitleSuppression: () => void; }) { return (): void => { deps.createMainWindow(); - deps.createInvisibleWindow(); deps.updateVisibleOverlayVisibility(); - deps.updateInvisibleOverlayVisibility(); + deps.syncOverlayMpvSubtitleSuppression(); }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 70286e2..ccfb6a8 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -14,6 +14,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' destroyTray: () => calls.push('destroy-tray'), stopConfigHotReload: () => calls.push('stop-config'), restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), + restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), stopSubtitleWebsocket: () => calls.push('stop-ws'), stopTexthookerService: () => calls.push('stop-texthooker'), @@ -72,6 +73,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { destroyTray: () => {}, stopConfigHotReload: () => {}, restorePreviousSecondarySubVisibility: () => {}, + restoreMpvSubVisibilityForInvisibleOverlay: () => {}, unregisterAllGlobalShortcuts: () => {}, stopSubtitleWebsocket: () => {}, stopTexthookerService: () => {}, diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index e897739..6c661b6 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -21,6 +21,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { destroyTray: () => void; stopConfigHotReload: () => void; restorePreviousSecondarySubVisibility: () => void; + restoreMpvSubVisibilityForInvisibleOverlay: () => void; unregisterAllGlobalShortcuts: () => void; stopSubtitleWebsocket: () => void; stopTexthookerService: () => void; @@ -51,6 +52,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { destroyTray: () => deps.destroyTray(), stopConfigHotReload: () => deps.stopConfigHotReload(), restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(), + restoreMpvSubVisibilityForInvisibleOverlay: () => + deps.restoreMpvSubVisibilityForInvisibleOverlay(), unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), stopTexthookerService: () => deps.stopTexthookerService(), diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 341359d..eba097e 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -16,6 +16,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler destroyTray: () => {}, stopConfigHotReload: () => {}, restorePreviousSecondarySubVisibility: () => {}, + restoreMpvSubVisibilityForInvisibleOverlay: () => {}, unregisterAllGlobalShortcuts: () => {}, stopSubtitleWebsocket: () => {}, stopTexthookerService: () => {}, @@ -43,9 +44,8 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler }, restoreWindowsOnActivateMainDeps: { createMainWindow: () => {}, - createInvisibleWindow: () => {}, updateVisibleOverlayVisibility: () => {}, - updateInvisibleOverlayVisibility: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, }, }); diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 56ec606..a6cceae 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -33,6 +33,7 @@ export function createHandleMpvSecondarySubtitleChangeHandler(deps: { export function createHandleMpvMediaPathChangeHandler(deps: { updateCurrentMediaPath: (path: string) => void; reportJellyfinRemoteStopped: () => void; + restoreMpvSubVisibilityForInvisibleOverlay: () => void; getCurrentAnilistMediaKey: () => string | null; resetAnilistMediaTracking: (mediaKey: string | null) => void; maybeProbeAnilistDuration: (mediaKey: string) => void; @@ -44,6 +45,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { deps.updateCurrentMediaPath(path); if (!path) { deps.reportJellyfinRemoteStopped(); + deps.restoreMpvSubVisibilityForInvisibleOverlay(); } const mediaKey = deps.getCurrentAnilistMediaKey(); deps.resetAnilistMediaTracking(mediaKey); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 76c5e00..28d7685 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -8,6 +8,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { const bind = createBindMpvMainEventHandlersHandler({ reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), hasInitialJellyfinPlayArg: () => false, isOverlayRuntimeInitialized: () => false, isQuitOnDisconnectArmed: () => false, @@ -35,6 +36,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`), updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`), + restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'), getCurrentAnilistMediaKey: () => 'media-key', resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), @@ -62,6 +64,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { }); handlers.get('subtitle-change')?.({ text: 'line' }); + handlers.get('media-path-change')?.({ path: '' }); handlers.get('media-title-change')?.({ title: 'Episode 1' }); handlers.get('time-pos-change')?.({ time: 2.5 }); handlers.get('pause-change')?.({ paused: true }); @@ -70,6 +73,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { assert.ok(calls.includes('broadcast-sub:line')); assert.ok(calls.includes('subtitle-change:line')); assert.ok(calls.includes('media-title:Episode 1')); + assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-guess-state')); assert.ok(calls.includes('notify-title:Episode 1')); assert.ok(calls.includes('progress:normal')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 16be17e..d1a33f3 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -19,6 +19,7 @@ type MpvEventClient = Parameters void; + syncOverlayMpvSubtitleSuppression: () => void; hasInitialJellyfinPlayArg: () => boolean; isOverlayRuntimeInitialized: () => boolean; isQuitOnDisconnectArmed: () => boolean; @@ -42,6 +43,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { broadcastSecondarySubtitle: (text: string) => void; updateCurrentMediaPath: (path: string) => void; + restoreMpvSubVisibilityForInvisibleOverlay: () => void; getCurrentAnilistMediaKey: () => string | null; resetAnilistMediaTracking: (mediaKey: string | null) => void; maybeProbeAnilistDuration: (mediaKey: string) => void; @@ -63,6 +65,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), refreshDiscordPresence: () => deps.refreshDiscordPresence(), + syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), @@ -94,6 +97,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({ updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + restoreMpvSubVisibilityForInvisibleOverlay: () => + deps.restoreMpvSubVisibilityForInvisibleOverlay(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index f19d782..54e2844 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -32,6 +32,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as }, quitApp: () => calls.push('quit'), reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), maybeRunAnilistPostWatchUpdate: async () => { calls.push('anilist-post-watch'); }, @@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as calls.push(`broadcast:${channel}:${String(payload)}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'), getCurrentAnilistMediaKey: () => 'media-key', resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), @@ -59,6 +61,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.scheduleQuitCheck(() => calls.push('scheduled-callback')); deps.quitApp(); deps.reportJellyfinRemoteStopped(); + deps.syncOverlayMpvSubtitleSuppression(); deps.recordImmersionSubtitleLine('x', 0, 1); assert.equal(deps.hasSubtitleTimingTracker(), true); deps.recordSubtitleTiming('y', 0, 1); @@ -72,6 +75,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.broadcastSubtitleAss('ass'); deps.broadcastSecondarySubtitle('sec'); deps.updateCurrentMediaPath('/tmp/video'); + deps.restoreMpvSubVisibilityForInvisibleOverlay(); assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key'); deps.resetAnilistMediaTracking('media-key'); deps.maybeProbeAnilistDuration('media-key'); @@ -91,8 +95,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.equal(appState.playbackPaused, true); assert.equal(appState.previousSecondarySubVisibility, true); assert.ok(calls.includes('remote-stopped')); + assert.ok(calls.includes('sync-overlay-mpv-sub')); assert.ok(calls.includes('anilist-post-watch')); assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('metrics')); assert.ok(calls.includes('presence-refresh')); + assert.ok(calls.includes('restore-mpv-sub')); }); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index d70e77e..802ed00 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -21,11 +21,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { scheduleQuitCheck: (callback: () => void) => void; quitApp: () => void; reportJellyfinRemoteStopped: () => void; + syncOverlayMpvSubtitleSuppression: () => void; maybeRunAnilistPostWatchUpdate: () => Promise; logSubtitleTimingError: (message: string, error: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; onSubtitleChange: (text: string) => void; updateCurrentMediaPath: (path: string) => void; + restoreMpvSubVisibilityForInvisibleOverlay: () => void; getCurrentAnilistMediaKey: () => string | null; resetAnilistMediaTracking: (mediaKey: string | null) => void; maybeProbeAnilistDuration: (mediaKey: string) => void; @@ -39,6 +41,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }) { return () => ({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay), isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized, isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(), @@ -68,6 +71,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { broadcastSecondarySubtitle: (text: string) => deps.broadcastToOverlayWindows('secondary-subtitle:set', text), updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), + restoreMpvSubVisibilityForInvisibleOverlay: () => + deps.restoreMpvSubVisibilityForInvisibleOverlay(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey), diff --git a/src/main/runtime/overlay-mpv-sub-visibility.test.ts b/src/main/runtime/overlay-mpv-sub-visibility.test.ts new file mode 100644 index 0000000..59b9a11 --- /dev/null +++ b/src/main/runtime/overlay-mpv-sub-visibility.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createEnsureOverlayMpvSubtitlesHiddenHandler, + createRestoreOverlayMpvSubtitlesHandler, +} from './overlay-mpv-sub-visibility'; + +type VisibilityState = { + savedSubVisibility: boolean | null; + savedSecondarySubVisibility: boolean | null; + revision: number; +}; + +test('ensure overlay mpv subtitle suppression captures previous visibility then hides subtitles', async () => { + const state: VisibilityState = { + savedSubVisibility: null, + savedSecondarySubVisibility: null, + revision: 0, + }; + const calls: boolean[] = []; + + const ensureHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (_name: string) => 'no', + }), + getSavedSubVisibility: () => state.savedSubVisibility, + setSavedSubVisibility: (visible) => { + state.savedSubVisibility = visible; + }, + getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility, + setSavedSecondarySubVisibility: (visible) => { + state.savedSecondarySubVisibility = visible; + }, + getRevision: () => state.revision, + setRevision: (revision) => { + state.revision = revision; + }, + setMpvSubVisibility: (visible) => { + calls.push(visible); + }, + setMpvSecondarySubVisibility: (visible) => { + calls.push(visible); + }, + logWarn: () => {}, + }); + + await ensureHidden(); + + assert.equal(state.savedSubVisibility, false); + assert.equal(state.savedSecondarySubVisibility, false); + assert.equal(state.revision, 1); + assert.deepEqual(calls, [false, false]); +}); + +test('restore overlay mpv subtitle suppression restores saved visibility', () => { + const state: VisibilityState = { + savedSubVisibility: false, + savedSecondarySubVisibility: true, + revision: 4, + }; + const calls: boolean[] = []; + + const restore = createRestoreOverlayMpvSubtitlesHandler({ + getSavedSubVisibility: () => state.savedSubVisibility, + setSavedSubVisibility: (visible) => { + state.savedSubVisibility = visible; + }, + getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility, + setSavedSecondarySubVisibility: (visible) => { + state.savedSecondarySubVisibility = visible; + }, + getRevision: () => state.revision, + setRevision: (revision) => { + state.revision = revision; + }, + isMpvConnected: () => true, + shouldKeepSuppressedFromVisibleOverlayBinding: () => false, + setMpvSubVisibility: (visible) => { + calls.push(visible); + }, + setMpvSecondarySubVisibility: (visible) => { + calls.push(visible); + }, + }); + + restore(); + + assert.equal(state.savedSubVisibility, null); + assert.equal(state.savedSecondarySubVisibility, null); + assert.equal(state.revision, 5); + assert.deepEqual(calls, [false, true]); +}); + +test('restore keeps mpv subtitles hidden when visible-overlay binding still requires suppression', () => { + const state: VisibilityState = { + savedSubVisibility: true, + savedSecondarySubVisibility: true, + revision: 9, + }; + const calls: boolean[] = []; + + const restore = createRestoreOverlayMpvSubtitlesHandler({ + getSavedSubVisibility: () => state.savedSubVisibility, + setSavedSubVisibility: (visible) => { + state.savedSubVisibility = visible; + }, + getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility, + setSavedSecondarySubVisibility: (visible) => { + state.savedSecondarySubVisibility = visible; + }, + getRevision: () => state.revision, + setRevision: (revision) => { + state.revision = revision; + }, + isMpvConnected: () => true, + shouldKeepSuppressedFromVisibleOverlayBinding: () => true, + setMpvSubVisibility: (visible) => { + calls.push(visible); + }, + setMpvSecondarySubVisibility: (visible) => { + calls.push(visible); + }, + }); + + restore(); + + assert.equal(state.savedSubVisibility, true); + assert.equal(state.savedSecondarySubVisibility, true); + assert.equal(state.revision, 10); + assert.deepEqual(calls, [false, false]); +}); + +test('restore defers mpv subtitle restore while mpv is disconnected', () => { + const state: VisibilityState = { + savedSubVisibility: true, + savedSecondarySubVisibility: false, + revision: 2, + }; + const calls: boolean[] = []; + + const restore = createRestoreOverlayMpvSubtitlesHandler({ + getSavedSubVisibility: () => state.savedSubVisibility, + setSavedSubVisibility: (visible) => { + state.savedSubVisibility = visible; + }, + getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility, + setSavedSecondarySubVisibility: (visible) => { + state.savedSecondarySubVisibility = visible; + }, + getRevision: () => state.revision, + setRevision: (revision) => { + state.revision = revision; + }, + isMpvConnected: () => false, + shouldKeepSuppressedFromVisibleOverlayBinding: () => false, + setMpvSubVisibility: (visible) => { + calls.push(visible); + }, + setMpvSecondarySubVisibility: (visible) => { + calls.push(visible); + }, + }); + + restore(); + + assert.equal(state.savedSubVisibility, true); + assert.equal(state.revision, 3); + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/overlay-mpv-sub-visibility.ts b/src/main/runtime/overlay-mpv-sub-visibility.ts new file mode 100644 index 0000000..8a85468 --- /dev/null +++ b/src/main/runtime/overlay-mpv-sub-visibility.ts @@ -0,0 +1,147 @@ +type MpvVisibilityClient = { + connected: boolean; + requestProperty: (name: string) => Promise; +}; + +type RestoreOptions = { + respectVisibleOverlayBinding?: boolean; +}; + +function parseSubVisibility(value: unknown): boolean { + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'no' || normalized === 'false' || normalized === '0') { + return false; + } + if (normalized === 'yes' || normalized === 'true' || normalized === '1') { + return true; + } + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return value !== 0; + } + + return true; +} + +export function createEnsureOverlayMpvSubtitlesHiddenHandler(deps: { + getMpvClient: () => MpvVisibilityClient | null; + getSavedSubVisibility: () => boolean | null; + setSavedSubVisibility: (visible: boolean | null) => void; + getSavedSecondarySubVisibility: () => boolean | null; + setSavedSecondarySubVisibility: (visible: boolean | null) => void; + getRevision: () => number; + setRevision: (revision: number) => void; + setMpvSubVisibility: (visible: boolean) => void; + setMpvSecondarySubVisibility: (visible: boolean) => void; + logWarn: (message: string, error: unknown) => void; +}) { + return async (): Promise => { + const revision = deps.getRevision() + 1; + deps.setRevision(revision); + + const mpvClient = deps.getMpvClient(); + if (!mpvClient || !mpvClient.connected) { + return; + } + + if (deps.getSavedSubVisibility() === null) { + try { + const currentSubVisibility = await mpvClient.requestProperty('sub-visibility'); + if (revision !== deps.getRevision()) { + return; + } + deps.setSavedSubVisibility(parseSubVisibility(currentSubVisibility)); + } catch (error) { + if (revision !== deps.getRevision()) { + return; + } + deps.logWarn( + '[overlay] Failed to capture mpv sub-visibility; falling back to visible restore', + error, + ); + deps.setSavedSubVisibility(true); + } + } + + if (deps.getSavedSecondarySubVisibility() === null) { + try { + const currentSecondarySubVisibility = await mpvClient.requestProperty('secondary-sub-visibility'); + if (revision !== deps.getRevision()) { + return; + } + deps.setSavedSecondarySubVisibility(parseSubVisibility(currentSecondarySubVisibility)); + } catch (error) { + if (revision !== deps.getRevision()) { + return; + } + deps.logWarn( + '[overlay] Failed to capture secondary mpv sub-visibility; falling back to visible restore', + error, + ); + deps.setSavedSecondarySubVisibility(true); + } + } + + if (revision !== deps.getRevision()) { + return; + } + + deps.setMpvSubVisibility(false); + deps.setMpvSecondarySubVisibility(false); + }; +} + +export function createRestoreOverlayMpvSubtitlesHandler(deps: { + getSavedSubVisibility: () => boolean | null; + setSavedSubVisibility: (visible: boolean | null) => void; + getSavedSecondarySubVisibility: () => boolean | null; + setSavedSecondarySubVisibility: (visible: boolean | null) => void; + getRevision: () => number; + setRevision: (revision: number) => void; + isMpvConnected: () => boolean; + shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; + setMpvSecondarySubVisibility: (visible: boolean) => void; +}) { + return (options?: RestoreOptions): void => { + deps.setRevision(deps.getRevision() + 1); + + const savedVisibility = deps.getSavedSubVisibility(); + const respectVisibleOverlayBinding = options?.respectVisibleOverlayBinding ?? true; + if ( + respectVisibleOverlayBinding && + deps.shouldKeepSuppressedFromVisibleOverlayBinding() + ) { + deps.setMpvSubVisibility(false); + deps.setMpvSecondarySubVisibility(false); + return; + } + + const hasSecondarySavedVisibility = deps.getSavedSecondarySubVisibility() !== null; + + if (savedVisibility === null && !hasSecondarySavedVisibility) { + return; + } + + if (!deps.isMpvConnected()) { + return; + } + + if (savedVisibility !== null) { + deps.setMpvSubVisibility(savedVisibility); + } + const savedSecondaryVisibility = deps.getSavedSecondarySubVisibility(); + if (savedSecondaryVisibility !== null) { + deps.setMpvSecondarySubVisibility(savedSecondaryVisibility); + } + + deps.setSavedSubVisibility(null); + deps.setSavedSecondarySubVisibility(null); + }; +} diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts index 862ad77..ceaf216 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.test.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -1,11 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler, createBuildCreateModalWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler, - createBuildCreateSecondaryWindowMainDepsHandler, } from './overlay-window-factory-main-deps'; test('overlay window factory main deps builders return mapped handlers', () => { @@ -13,7 +11,6 @@ test('overlay window factory main deps builders return mapped handlers', () => { const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({ createOverlayWindowCore: (kind) => ({ kind }), isDev: true, - getOverlayDebugVisualizationEnabled: () => false, ensureOverlayWindowLevel: () => calls.push('ensure-level'), onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), @@ -24,7 +21,6 @@ test('overlay window factory main deps builders return mapped handlers', () => { const overlayDeps = buildOverlayDeps(); assert.equal(overlayDeps.isDev, true); - assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false); assert.equal(overlayDeps.isOverlayVisible('visible'), true); const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ @@ -34,20 +30,6 @@ test('overlay window factory main deps builders return mapped handlers', () => { const mainDeps = buildMainDeps(); mainDeps.setMainWindow(null); - const buildInvisibleDeps = createBuildCreateInvisibleWindowMainDepsHandler({ - createOverlayWindow: () => ({ id: 'invisible' }), - setInvisibleWindow: () => calls.push('set-invisible'), - }); - const invisibleDeps = buildInvisibleDeps(); - invisibleDeps.setInvisibleWindow(null); - - const buildSecondaryDeps = createBuildCreateSecondaryWindowMainDepsHandler({ - createOverlayWindow: () => ({ id: 'secondary' }), - setSecondaryWindow: () => calls.push('set-secondary'), - }); - const secondaryDeps = buildSecondaryDeps(); - secondaryDeps.setSecondaryWindow(null); - const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({ createOverlayWindow: () => ({ id: 'modal' }), setModalWindow: () => calls.push('set-modal'), @@ -55,5 +37,5 @@ test('overlay window factory main deps builders return mapped handlers', () => { const modalDeps = buildModalDeps(); modalDeps.setModalWindow(null); - assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary', 'set-modal']); + assert.deepEqual(calls, ['set-main', 'set-modal']); }); diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index 5667130..d2fb69d 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -1,30 +1,27 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { createOverlayWindowCore: ( - kind: 'visible' | 'invisible' | 'secondary' | 'modal', + kind: 'visible' | 'modal', options: { isDev: boolean; - overlayDebugVisualizationEnabled: boolean; ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean; + isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void; + onWindowClosed: (windowKind: 'visible' | 'modal') => void; }, ) => TWindow; isDev: boolean; - getOverlayDebugVisualizationEnabled: () => boolean; ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean; + isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void; + onWindowClosed: (windowKind: 'visible' | 'modal') => void; }) { return () => ({ createOverlayWindowCore: deps.createOverlayWindowCore, isDev: deps.isDev, - getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled, ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, @@ -35,7 +32,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { } export function createBuildCreateMainWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; + createOverlayWindow: (kind: 'visible' | 'modal') => TWindow; setMainWindow: (window: TWindow | null) => void; }) { return () => ({ @@ -44,28 +41,8 @@ export function createBuildCreateMainWindowMainDepsHandler(deps: { }); } -export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; - setInvisibleWindow: (window: TWindow | null) => void; -}) { - return () => ({ - createOverlayWindow: deps.createOverlayWindow, - setInvisibleWindow: deps.setInvisibleWindow, - }); -} - -export function createBuildCreateSecondaryWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; - setSecondaryWindow: (window: TWindow | null) => void; -}) { - return () => ({ - createOverlayWindow: deps.createOverlayWindow, - setSecondaryWindow: deps.setSecondaryWindow, - }); -} - export function createBuildCreateModalWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; + createOverlayWindow: (kind: 'visible' | 'modal') => TWindow; setModalWindow: (window: TWindow | null) => void; }) { return () => ({ diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts index 5ad937b..8d45f98 100644 --- a/src/main/runtime/overlay-window-factory.test.ts +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -1,11 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - createCreateInvisibleWindowHandler, createCreateMainWindowHandler, createCreateModalWindowHandler, createCreateOverlayWindowHandler, - createCreateSecondaryWindowHandler, } from './overlay-window-factory'; test('create overlay window handler forwards options and kind', () => { @@ -15,16 +13,14 @@ test('create overlay window handler forwards options and kind', () => { createOverlayWindowCore: (kind, options) => { calls.push(`kind:${kind}`); assert.equal(options.isDev, true); - assert.equal(options.overlayDebugVisualizationEnabled, false); assert.equal(options.isOverlayVisible('visible'), true); - assert.equal(options.isOverlayVisible('invisible'), false); + assert.equal(options.isOverlayVisible('modal'), false); options.onRuntimeOptionsChanged(); options.setOverlayDebugVisualizationEnabled(true); options.onWindowClosed(kind); return window; }, isDev: true, - getOverlayDebugVisualizationEnabled: () => false, ensureOverlayWindowLevel: () => {}, onRuntimeOptionsChanged: () => calls.push('runtime-options'), setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), @@ -52,36 +48,6 @@ test('create main window handler stores visible window', () => { assert.deepEqual(calls, ['create:visible', 'set:visible']); }); -test('create invisible window handler stores invisible window', () => { - const calls: string[] = []; - const invisibleWindow = { id: 'invisible' }; - const createInvisibleWindow = createCreateInvisibleWindowHandler({ - createOverlayWindow: (kind) => { - calls.push(`create:${kind}`); - return invisibleWindow; - }, - setInvisibleWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), - }); - - assert.equal(createInvisibleWindow(), invisibleWindow); - assert.deepEqual(calls, ['create:invisible', 'set:invisible']); -}); - -test('create secondary window handler stores secondary window', () => { - const calls: string[] = []; - const secondaryWindow = { id: 'secondary' }; - const createSecondaryWindow = createCreateSecondaryWindowHandler({ - createOverlayWindow: (kind) => { - calls.push(`create:${kind}`); - return secondaryWindow; - }, - setSecondaryWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), - }); - - assert.equal(createSecondaryWindow(), secondaryWindow); - assert.deepEqual(calls, ['create:secondary', 'set:secondary']); -}); - test('create modal window handler stores modal window', () => { const calls: string[] = []; const modalWindow = { id: 'modal' }; diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index c3431f6..3b6439f 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -1,11 +1,10 @@ -type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal'; +type OverlayWindowKind = 'visible' | 'modal'; export function createCreateOverlayWindowHandler(deps: { createOverlayWindowCore: ( kind: OverlayWindowKind, options: { isDev: boolean; - overlayDebugVisualizationEnabled: boolean; ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; @@ -15,7 +14,6 @@ export function createCreateOverlayWindowHandler(deps: { }, ) => TWindow; isDev: boolean; - getOverlayDebugVisualizationEnabled: () => boolean; ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; @@ -26,7 +24,6 @@ export function createCreateOverlayWindowHandler(deps: { return (kind: OverlayWindowKind): TWindow => { return deps.createOverlayWindowCore(kind, { isDev: deps.isDev, - overlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled(), ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, @@ -48,28 +45,6 @@ export function createCreateMainWindowHandler(deps: { }; } -export function createCreateInvisibleWindowHandler(deps: { - createOverlayWindow: (kind: OverlayWindowKind) => TWindow; - setInvisibleWindow: (window: TWindow | null) => void; -}) { - return (): TWindow => { - const window = deps.createOverlayWindow('invisible'); - deps.setInvisibleWindow(window); - return window; - }; -} - -export function createCreateSecondaryWindowHandler(deps: { - createOverlayWindow: (kind: OverlayWindowKind) => TWindow; - setSecondaryWindow: (window: TWindow | null) => void; -}) { - return (): TWindow => { - const window = deps.createOverlayWindow('secondary'); - deps.setSecondaryWindow(window); - return window; - }; -} - export function createCreateModalWindowHandler(deps: { createOverlayWindow: (kind: OverlayWindowKind) => TWindow; setModalWindow: (window: TWindow | null) => void; diff --git a/src/main/runtime/overlay-window-runtime-handlers.test.ts b/src/main/runtime/overlay-window-runtime-handlers.test.ts index 4de2d55..f4c38f4 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.test.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.test.ts @@ -2,10 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createOverlayWindowRuntimeHandlers } from './overlay-window-runtime-handlers'; -test('overlay window runtime handlers compose create/main/invisible handlers', () => { +test('overlay window runtime handlers compose create/main/modal handlers', () => { let mainWindow: { kind: string } | null = null; - let invisibleWindow: { kind: string } | null = null; - let secondaryWindow: { kind: string } | null = null; let modalWindow: { kind: string } | null = null; let debugEnabled = false; const calls: string[] = []; @@ -14,7 +12,6 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( createOverlayWindowDeps: { createOverlayWindowCore: (kind) => ({ kind }), isDev: true, - getOverlayDebugVisualizationEnabled: () => debugEnabled, ensureOverlayWindowLevel: () => calls.push('ensure-level'), onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), setOverlayDebugVisualizationEnabled: (enabled) => { @@ -27,29 +24,17 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( setMainWindow: (window) => { mainWindow = window; }, - setInvisibleWindow: (window) => { - invisibleWindow = window; - }, - setSecondaryWindow: (window) => { - secondaryWindow = window; - }, setModalWindow: (window) => { modalWindow = window; }, }); assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' }); - assert.deepEqual(runtime.createOverlayWindow('invisible'), { kind: 'invisible' }); - assert.deepEqual(runtime.createOverlayWindow('secondary'), { kind: 'secondary' }); + assert.deepEqual(runtime.createOverlayWindow('modal'), { kind: 'modal' }); assert.deepEqual(runtime.createMainWindow(), { kind: 'visible' }); assert.deepEqual(mainWindow, { kind: 'visible' }); - assert.deepEqual(runtime.createInvisibleWindow(), { kind: 'invisible' }); - assert.deepEqual(invisibleWindow, { kind: 'invisible' }); - - assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' }); - assert.deepEqual(secondaryWindow, { kind: 'secondary' }); assert.deepEqual(runtime.createModalWindow(), { kind: 'modal' }); assert.deepEqual(modalWindow, { kind: 'modal' }); diff --git a/src/main/runtime/overlay-window-runtime-handlers.ts b/src/main/runtime/overlay-window-runtime-handlers.ts index b3564c6..3c301f6 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.ts @@ -1,16 +1,12 @@ import { - createCreateInvisibleWindowHandler, createCreateMainWindowHandler, createCreateModalWindowHandler, createCreateOverlayWindowHandler, - createCreateSecondaryWindowHandler, } from './overlay-window-factory'; import { - createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler, createBuildCreateModalWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler, - createBuildCreateSecondaryWindowMainDepsHandler, } from './overlay-window-factory-main-deps'; type CreateOverlayWindowMainDeps = Parameters< @@ -20,8 +16,6 @@ type CreateOverlayWindowMainDeps = Parameters< export function createOverlayWindowRuntimeHandlers(deps: { createOverlayWindowDeps: CreateOverlayWindowMainDeps; setMainWindow: (window: TWindow | null) => void; - setInvisibleWindow: (window: TWindow | null) => void; - setSecondaryWindow: (window: TWindow | null) => void; setModalWindow: (window: TWindow | null) => void; }) { const createOverlayWindow = createCreateOverlayWindowHandler( @@ -33,18 +27,6 @@ export function createOverlayWindowRuntimeHandlers(deps: { setMainWindow: (window) => deps.setMainWindow(window), })(), ); - const createInvisibleWindow = createCreateInvisibleWindowHandler( - createBuildCreateInvisibleWindowMainDepsHandler({ - createOverlayWindow: (kind) => createOverlayWindow(kind), - setInvisibleWindow: (window) => deps.setInvisibleWindow(window), - })(), - ); - const createSecondaryWindow = createCreateSecondaryWindowHandler( - createBuildCreateSecondaryWindowMainDepsHandler({ - createOverlayWindow: (kind) => createOverlayWindow(kind), - setSecondaryWindow: (window) => deps.setSecondaryWindow(window), - })(), - ); const createModalWindow = createCreateModalWindowHandler( createBuildCreateModalWindowMainDepsHandler({ createOverlayWindow: (kind) => createOverlayWindow(kind), @@ -55,8 +37,6 @@ export function createOverlayWindowRuntimeHandlers(deps: { return { createOverlayWindow, createMainWindow, - createInvisibleWindow, - createSecondaryWindow, createModalWindow, }; } diff --git a/src/main/state.ts b/src/main/state.ts index 433b260..cff85d8 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -156,8 +156,6 @@ export interface AppState { currentSubText: string; currentSubAssText: string; currentSubtitleData: SubtitleData | null; - hoveredSubtitleTokenIndex: number | null; - hoveredSubtitleRevision: number; windowTracker: BaseWindowTracker | null; subtitlePosition: SubtitlePosition | null; currentMediaPath: string | null; @@ -173,6 +171,9 @@ export interface AppState { secondarySubMode: SecondarySubMode; lastSecondarySubToggleAtMs: number; previousSecondarySubVisibility: boolean | null; + overlaySavedMpvSubVisibility: boolean | null; + overlaySavedSecondaryMpvSubVisibility: boolean | null; + overlayMpvSubVisibilityRevision: number; mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics; shortcutsRegistered: boolean; overlayRuntimeInitialized: boolean; @@ -230,8 +231,6 @@ export function createAppState(values: AppStateInitialValues): AppState { currentSubText: '', currentSubAssText: '', currentSubtitleData: null, - hoveredSubtitleTokenIndex: null, - hoveredSubtitleRevision: 0, windowTracker: null, subtitlePosition: null, currentMediaPath: null, @@ -247,6 +246,9 @@ export function createAppState(values: AppStateInitialValues): AppState { secondarySubMode: 'hover', lastSecondarySubToggleAtMs: 0, previousSecondarySubVisibility: null, + overlaySavedMpvSubVisibility: null, + overlaySavedSecondaryMpvSubVisibility: null, + overlayMpvSubVisibilityRevision: 0, mpvSubtitleRenderMetrics: { ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, }, diff --git a/src/preload.ts b/src/preload.ts index 0b5579d..e0d89c4 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -45,7 +45,6 @@ import type { RuntimeOptionId, RuntimeOptionState, RuntimeOptionValue, - MpvSubtitleRenderMetrics, OverlayContentMeasurement, ShortcutsConfig, ConfigHotReloadPayload, @@ -55,12 +54,80 @@ import { IPC_CHANNELS } from './shared/ipc/contracts'; const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); const overlayLayer = - overlayLayerFromArg === 'visible' || - overlayLayerFromArg === 'invisible' || - overlayLayerFromArg === 'secondary' || - overlayLayerFromArg === 'modal' - ? overlayLayerFromArg - : null; + overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'modal' ? overlayLayerFromArg : null; + +type EmptyListener = () => void; +type PayloadedListener = (payload: T) => void; + +function createQueuedIpcListener( + channel: string, +): (listener: EmptyListener) => void { + let count = 0; + const listeners: EmptyListener[] = []; + + const dispatch = (): void => { + if (listeners.length === 0) { + count += 1; + return; + } + for (const listener of listeners) { + listener(); + } + }; + + ipcRenderer.on(channel, () => { + dispatch(); + }); + + return (listener: EmptyListener): void => { + listeners.push(listener); + while (count > 0) { + count -= 1; + listener(); + } + }; +} + +function createQueuedIpcListenerWithPayload( + channel: string, + normalize: (payload: unknown) => T, +): (listener: PayloadedListener) => void { + const pending: T[] = []; + const listeners: PayloadedListener[] = []; + + const dispatch = (payload: T): void => { + if (listeners.length === 0) { + pending.push(payload); + return; + } + for (const listener of listeners) { + listener(payload); + } + }; + + ipcRenderer.on(channel, (_event: IpcRendererEvent, payloadArg: unknown) => { + dispatch(normalize(payloadArg)); + }); + + return (listener: PayloadedListener): void => { + listeners.push(listener); + while (pending.length > 0) { + const payload = pending.shift(); + listener(payload as T); + } + }; +} + +const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); +const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen); +const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.subsyncOpenManual, + (payload) => payload as SubsyncManualPayload, +); +const onKikuFieldGroupingRequestEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.kikuFieldGroupingRequest, + (payload) => payload as KikuFieldGroupingRequestData, +); const electronAPI: ElectronAPI = { getOverlayLayer: () => overlayLayer, @@ -94,16 +161,6 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), getCurrentSubtitleAss: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), - getMpvSubtitleRenderMetrics: () => - ipcRenderer.invoke(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics), - onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.mpvSubtitleRenderMetricsSet, - (_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => { - callback(metrics); - }, - ); - }, onSubtitleAss: (callback: (assText: string) => void) => { ipcRenderer.on( IPC_CHANNELS.event.subtitleAssSet, @@ -112,14 +169,6 @@ const electronAPI: ElectronAPI = { }, ); }, - onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.overlayDebugVisualizationSet, - (_event: IpcRendererEvent, enabled: boolean) => { - callback(enabled); - }, - ); - }, setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options); @@ -201,23 +250,11 @@ const electronAPI: ElectronAPI = { focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise, getSubtitleStyle: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle), - onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.subsyncOpenManual, - (_event: IpcRendererEvent, payload: SubsyncManualPayload) => { - callback(payload); - }, - ); - }, + onSubsyncManualOpen: onSubsyncManualOpenEvent, runSubsyncManual: (request: SubsyncManualRunRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), - onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.kikuFieldGroupingRequest, - (_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data), - ); - }, + onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent, kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request), @@ -242,27 +279,19 @@ const electronAPI: ElectronAPI = { }, ); }, - onOpenRuntimeOptions: (callback: () => void) => { - ipcRenderer.on(IPC_CHANNELS.event.runtimeOptionsOpen, () => { - callback(); - }); - }, - onOpenJimaku: (callback: () => void) => { - ipcRenderer.on(IPC_CHANNELS.event.jimakuOpen, () => { - callback(); - }); - }, + onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, + onOpenJimaku: onOpenJimakuEvent, appendClipboardVideoToQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, + notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { + ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal); + }, reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement); }, - reportHoveredSubtitleToken: (tokenIndex: number | null) => { - ipcRenderer.send('subtitle-token-hover:set', tokenIndex); - }, onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { ipcRenderer.on( IPC_CHANNELS.event.configHotReload, diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts index 65cde92..a94821c 100644 --- a/src/renderer/error-recovery.test.ts +++ b/src/renderer/error-recovery.test.ts @@ -2,6 +2,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createRendererRecoveryController } from './error-recovery.js'; +import { + YOMITAN_POPUP_IFRAME_SELECTOR, + hasYomitanPopupIframe, + isYomitanPopupIframe, +} from './yomitan-popup.js'; import { resolvePlatformInfo } from './utils/platform.js'; test('handleError logs context and recovers overlay state', () => { @@ -26,7 +31,6 @@ test('handleError logs context and recovers overlay state', () => { secondarySubtitlePreview: 'secondary', isOverlayInteractive: true, isOverSubtitle: true, - invisiblePositionEditMode: false, overlayLayer: 'visible', }), logError: (payload) => { @@ -72,8 +76,7 @@ test('handleError normalizes non-Error values', () => { secondarySubtitlePreview: '', isOverlayInteractive: false, isOverSubtitle: false, - invisiblePositionEditMode: false, - overlayLayer: 'invisible', + overlayLayer: 'visible', }), logError: (payload) => { payloads.push(payload); @@ -107,7 +110,6 @@ test('nested recovery errors are ignored while current recovery is active', () = secondarySubtitlePreview: '', isOverlayInteractive: true, isOverSubtitle: false, - invisiblePositionEditMode: true, overlayLayer: 'visible', }), logError: (payload) => { @@ -130,7 +132,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => { configurable: true, value: { electronAPI: { - getOverlayLayer: () => 'invisible', + getOverlayLayer: () => 'modal', }, location: { search: '?layer=visible' }, }, @@ -146,7 +148,6 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => { try { const info = resolvePlatformInfo(); assert.equal(info.overlayLayer, 'visible'); - assert.equal(info.isInvisibleLayer, false); } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'navigator', { @@ -156,7 +157,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => { } }); -test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => { +test('resolvePlatformInfo ignores legacy secondary layer and falls back to visible', () => { const previousWindow = (globalThis as { window?: unknown }).window; const previousNavigator = (globalThis as { navigator?: unknown }).navigator; @@ -179,9 +180,8 @@ test('resolvePlatformInfo supports secondary layer and disables mouse-ignore tog try { const info = resolvePlatformInfo(); - assert.equal(info.overlayLayer, 'secondary'); - assert.equal(info.isSecondaryLayer, true); - assert.equal(info.shouldToggleMouseIgnore, false); + assert.equal(info.overlayLayer, 'visible'); + assert.equal(info.shouldToggleMouseIgnore, true); } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'navigator', { @@ -225,3 +225,59 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles }); } }); + +test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => { + const createElement = (options: { + tagName: string; + id?: string; + classNames?: string[]; + }): Element => + ({ + tagName: options.tagName, + id: options.id ?? '', + classList: { + contains: (className: string) => (options.classNames ?? []).includes(className), + }, + }) as unknown as Element; + + assert.equal( + isYomitanPopupIframe( + createElement({ + tagName: 'IFRAME', + classNames: ['yomitan-popup'], + }), + ), + true, + ); + assert.equal( + isYomitanPopupIframe( + createElement({ + tagName: 'IFRAME', + id: 'yomitan-popup-123', + }), + ), + true, + ); + assert.equal( + isYomitanPopupIframe( + createElement({ + tagName: 'IFRAME', + id: 'something-else', + }), + ), + false, + ); +}); + +test('hasYomitanPopupIframe queries for modern + legacy selector', () => { + let selector = ''; + const root = { + querySelector: (value: string) => { + selector = value; + return {}; + }, + } as unknown as ParentNode; + + assert.equal(hasYomitanPopupIframe(root), true); + assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR); +}); diff --git a/src/renderer/error-recovery.ts b/src/renderer/error-recovery.ts index a36de48..f8330a1 100644 --- a/src/renderer/error-recovery.ts +++ b/src/renderer/error-recovery.ts @@ -16,8 +16,7 @@ export type RendererRecoverySnapshot = { secondarySubtitlePreview: string; isOverlayInteractive: boolean; isOverSubtitle: boolean; - invisiblePositionEditMode: boolean; - overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal'; + overlayLayer: 'visible' | 'modal'; }; type NormalizedRendererError = { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 620f883..da1682a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -18,7 +18,6 @@ import type { KikuDuplicateCardInfo, - MpvSubtitleRenderMetrics, RuntimeOptionState, SecondarySubMode, SubtitleData, @@ -84,10 +83,7 @@ function syncSettingsModalSubtitleSuppression(): void { const subtitleRenderer = createSubtitleRenderer(ctx); const measurementReporter = createOverlayContentMeasurementReporter(ctx); -const positioning = createPositioningController(ctx, { - modalStateReader: { isAnySettingsModalOpen }, - applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize, -}); +const positioning = createPositioningController(ctx); const runtimeOptionsModal = createRuntimeOptionsModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, @@ -115,25 +111,15 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, openSessionHelpModal: sessionHelpModal.openSessionHelpModal, - saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit, - cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit, - setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode, - applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition, - updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud, appendClipboardVideoToQueue: () => { void window.electronAPI.appendClipboardVideoToQueue(); }, }); const mouseHandlers = createMouseHandlers(ctx, { modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, - applyInvisibleSubtitleLayoutFromMpvMetrics: - positioning.applyInvisibleSubtitleLayoutFromMpvMetrics, applyYPercent: positioning.applyYPercent, getCurrentYPercent: positioning.getCurrentYPercent, persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, - reportHoveredTokenIndex: (tokenIndex: number | null) => { - window.electronAPI.reportHoveredSubtitleToken(tokenIndex); - }, }); let lastSubtitlePreview = ''; @@ -179,9 +165,6 @@ function dismissActiveUiAfterError(): void { function restoreOverlayInteractionAfterError(): void { ctx.state.isOverSubtitle = false; - if (ctx.state.invisiblePositionEditMode) { - positioning.setInvisiblePositionEditMode(false); - } ctx.dom.overlay.classList.remove('interactive'); if (ctx.platform.shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); @@ -212,7 +195,6 @@ const recovery = createRendererRecoveryController({ secondarySubtitlePreview: lastSecondarySubtitlePreview, isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'), isOverSubtitle: ctx.state.isOverSubtitle, - invisiblePositionEditMode: ctx.state.invisiblePositionEditMode, overlayLayer: ctx.platform.overlayLayer, }), logError: (payload) => { @@ -222,6 +204,41 @@ const recovery = createRendererRecoveryController({ registerRendererGlobalErrorHandlers(window, recovery); +function registerModalOpenHandlers(): void { + window.electronAPI.onOpenRuntimeOptions(() => { + runGuardedAsync('runtime-options:open', async () => { + try { + await runtimeOptionsModal.openRuntimeOptionsModal(); + window.electronAPI.notifyOverlayModalOpened('runtime-options'); + } catch { + runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); + window.electronAPI.notifyOverlayModalClosed('runtime-options'); + syncSettingsModalSubtitleSuppression(); + } + }); + }); + window.electronAPI.onOpenJimaku(() => { + runGuarded('jimaku:open', () => { + jimakuModal.openJimakuModal(); + window.electronAPI.notifyOverlayModalOpened('jimaku'); + }); + }); + window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { + runGuarded('subsync:manual-open', () => { + subsyncModal.openSubsyncModal(payload); + window.electronAPI.notifyOverlayModalOpened('subsync'); + }); + }); + window.electronAPI.onKikuFieldGroupingRequest( + (data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => { + runGuarded('kiku:field-grouping-open', () => { + kikuModal.openKikuFieldGroupingModal(data); + window.electronAPI.notifyOverlayModalOpened('kiku'); + }); + }, + ); +} + function runGuarded(action: string, fn: () => void): void { try { fn(); @@ -238,6 +255,8 @@ function runGuardedAsync(action: string, fn: () => Promise | void): void { }); } +registerModalOpenHandlers(); + async function init(): Promise { document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); if (ctx.platform.isMacOSPlatform) { @@ -252,41 +271,17 @@ async function init(): Promise { lastSubtitlePreview = truncateForErrorLog(data.text); } subtitleRenderer.renderSubtitle(data); - if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) { - positioning.applyInvisibleSubtitleLayoutFromMpvMetrics( - ctx.state.mpvSubtitleRenderMetrics, - 'subtitle-change', - ); - } measurementReporter.schedule(); }); }); window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { runGuarded('subtitle-position:update', () => { - if (ctx.platform.isInvisibleLayer) { - positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change'); - } else { - positioning.applyStoredSubtitlePosition(position, 'media-change'); - } + positioning.applyStoredSubtitlePosition(position, 'media-change'); measurementReporter.schedule(); }); }); - if (ctx.platform.isInvisibleLayer) { - window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => { - runGuarded('mpv-metrics:update', () => { - positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event'); - measurementReporter.schedule(); - }); - }); - window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { - runGuarded('overlay-debug-visualization:update', () => { - document.body.classList.toggle('debug-invisible-visualization', enabled); - }); - }); - } - const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw(); lastSubtitlePreview = truncateForErrorLog(initialSubtitle); subtitleRenderer.renderSubtitle(initialSubtitle); @@ -310,17 +305,11 @@ async function init(): Promise { subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); measurementReporter.schedule(); - const hoverTarget = ctx.platform.isInvisibleLayer - ? ctx.dom.subtitleRoot - : ctx.dom.subtitleContainer; - hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter); - hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); + ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter); + ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter); ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); - mouseHandlers.setupInvisibleHoverSelection(); - mouseHandlers.setupInvisibleTokenHoverReporter(); - positioning.setupInvisiblePositionEditHud(); mouseHandlers.setupResizeHandler(); mouseHandlers.setupSelectionObserver(); mouseHandlers.setupYomitanObserver(); @@ -348,59 +337,14 @@ async function init(): Promise { measurementReporter.schedule(); }); }); - window.electronAPI.onOpenRuntimeOptions(() => { - runGuardedAsync('runtime-options:open', async () => { - try { - await runtimeOptionsModal.openRuntimeOptionsModal(); - } catch { - runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); - window.electronAPI.notifyOverlayModalClosed('runtime-options'); - syncSettingsModalSubtitleSuppression(); - } - }); - }); - window.electronAPI.onOpenJimaku(() => { - runGuarded('jimaku:open', () => { - jimakuModal.openJimakuModal(); - }); - }); - window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { - runGuarded('subsync:manual-open', () => { - subsyncModal.openSubsyncModal(payload); - }); - }); - window.electronAPI.onKikuFieldGroupingRequest( - (data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => { - runGuarded('kiku:field-grouping-open', () => { - kikuModal.openKikuFieldGroupingModal(data); - }); - }, - ); - - if (!ctx.platform.isInvisibleLayer) { - mouseHandlers.setupDragging(); - } + mouseHandlers.setupDragging(); await keyboardHandlers.setupMpvInputForwarding(); subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); - if (ctx.platform.isInvisibleLayer) { - positioning.applyInvisibleStoredSubtitlePosition( - await window.electronAPI.getSubtitlePosition(), - 'startup', - ); - positioning.applyInvisibleSubtitleLayoutFromMpvMetrics( - await window.electronAPI.getMpvSubtitleRenderMetrics(), - 'startup', - ); - } else { - positioning.applyStoredSubtitlePosition( - await window.electronAPI.getSubtitlePosition(), - 'startup', - ); - measurementReporter.schedule(); - } + positioning.applyStoredSubtitlePosition(await window.electronAPI.getSubtitlePosition(), 'startup'); + measurementReporter.schedule(); if (ctx.platform.shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); @@ -421,7 +365,7 @@ function setupDragDropToMpvQueue(): void { const clearDropInteractive = (): void => { dragDepth = 0; - if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) { + if (isAnyModalOpen() || ctx.state.isOverSubtitle) { return; } ctx.dom.overlay.classList.remove('interactive'); diff --git a/src/renderer/style.css b/src/renderer/style.css index 2aea994..8d29a15 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -280,6 +280,8 @@ body { text-align: center; font-size: 35px; line-height: var(--visible-sub-line-height, 1.32); + overflow-wrap: anywhere; + word-break: keep-all; color: #cad3f5; --subtitle-known-word-color: #a6da95; --subtitle-n-plus-one-color: #c6a0f6; @@ -288,6 +290,8 @@ body { --subtitle-jlpt-n3-color: #f9e2af; --subtitle-jlpt-n4-color: #a6e3a1; --subtitle-jlpt-n5-color: #8aadf4; + --subtitle-hover-token-color: #f4dbd6; + --subtitle-hover-token-background-color: rgba(54, 58, 79, 0.84); --subtitle-frequency-single-color: #f5a97f; --subtitle-frequency-band-1-color: #ed8796; --subtitle-frequency-band-2-color: #f5a97f; @@ -300,6 +304,7 @@ body { /* Enable text selection for Yomitan */ user-select: text; cursor: text; + -webkit-text-fill-color: currentColor; } #subtitleRoot:empty { @@ -318,16 +323,21 @@ body.settings-modal-open #subtitleContainer { #subtitleRoot .c { display: inline; position: relative; + color: inherit; + -webkit-text-fill-color: currentColor !important; } #subtitleRoot .c:hover { - background: rgba(255, 255, 255, 0.15); + background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)); + color: var(--subtitle-hover-token-color, #f4dbd6) !important; + -webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important; border-radius: 2px; } #subtitleRoot .word { display: inline; position: relative; + -webkit-text-fill-color: currentColor !important; } #subtitleRoot .word.word-known { @@ -418,9 +428,103 @@ body.settings-modal-open #subtitleContainer { color: var(--subtitle-frequency-band-5-color, #8aadf4); } -#subtitleRoot .word:hover { - background: rgba(255, 255, 255, 0.2); +#subtitleRoot .word:not(.word-known):not(.word-n-plus-one):not(.word-frequency-single):not( + .word-frequency-band-1 + ):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not( + .word-frequency-band-5 + ):hover { + background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)); border-radius: 3px; + color: var(--subtitle-hover-token-color, #f4dbd6) !important; + -webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important; +} + +#subtitleRoot .word.word-known:hover, +#subtitleRoot .word.word-n-plus-one:hover, +#subtitleRoot .word.word-frequency-single:hover, +#subtitleRoot .word.word-frequency-band-1:hover, +#subtitleRoot .word.word-frequency-band-2:hover, +#subtitleRoot .word.word-frequency-band-3:hover, +#subtitleRoot .word.word-frequency-band-4:hover, +#subtitleRoot .word.word-frequency-band-5:hover { + background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)); + border-radius: 3px; + font-weight: 800; +} + +#subtitleRoot .word.word-known .c:hover, +#subtitleRoot .word.word-n-plus-one .c:hover, +#subtitleRoot .word.word-frequency-single .c:hover, +#subtitleRoot .word.word-frequency-band-1 .c:hover, +#subtitleRoot .word.word-frequency-band-2 .c:hover, +#subtitleRoot .word.word-frequency-band-3 .c:hover, +#subtitleRoot .word.word-frequency-band-4 .c:hover, +#subtitleRoot .word.word-frequency-band-5 .c:hover { + background: transparent; + color: inherit !important; + -webkit-text-fill-color: currentColor !important; +} + +#subtitleRoot::selection, +#subtitleRoot .word::selection, +#subtitleRoot .c::selection { + background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)); + color: var(--subtitle-hover-token-color, #f4dbd6) !important; + -webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important; +} + +#subtitleRoot *::selection { + background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)) !important; + color: var(--subtitle-hover-token-color, #f4dbd6) !important; + -webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important; +} + +#subtitleRoot .word.word-known::selection, +#subtitleRoot .word.word-known .c::selection { + color: var(--subtitle-known-word-color, #a6da95) !important; + -webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important; +} + +#subtitleRoot .word.word-n-plus-one::selection, +#subtitleRoot .word.word-n-plus-one .c::selection { + color: var(--subtitle-n-plus-one-color, #c6a0f6) !important; + -webkit-text-fill-color: var(--subtitle-n-plus-one-color, #c6a0f6) !important; +} + +#subtitleRoot .word.word-frequency-single::selection, +#subtitleRoot .word.word-frequency-single .c::selection { + color: var(--subtitle-frequency-single-color, #f5a97f) !important; + -webkit-text-fill-color: var(--subtitle-frequency-single-color, #f5a97f) !important; +} + +#subtitleRoot .word.word-frequency-band-1::selection, +#subtitleRoot .word.word-frequency-band-1 .c::selection { + color: var(--subtitle-frequency-band-1-color, #ed8796) !important; + -webkit-text-fill-color: var(--subtitle-frequency-band-1-color, #ed8796) !important; +} + +#subtitleRoot .word.word-frequency-band-2::selection, +#subtitleRoot .word.word-frequency-band-2 .c::selection { + color: var(--subtitle-frequency-band-2-color, #f5a97f) !important; + -webkit-text-fill-color: var(--subtitle-frequency-band-2-color, #f5a97f) !important; +} + +#subtitleRoot .word.word-frequency-band-3::selection, +#subtitleRoot .word.word-frequency-band-3 .c::selection { + color: var(--subtitle-frequency-band-3-color, #f9e2af) !important; + -webkit-text-fill-color: var(--subtitle-frequency-band-3-color, #f9e2af) !important; +} + +#subtitleRoot .word.word-frequency-band-4::selection, +#subtitleRoot .word.word-frequency-band-4 .c::selection { + color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important; + -webkit-text-fill-color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important; +} + +#subtitleRoot .word.word-frequency-band-5::selection, +#subtitleRoot .word.word-frequency-band-5 .c::selection { + color: var(--subtitle-frequency-band-5-color, #8aadf4) !important; + -webkit-text-fill-color: var(--subtitle-frequency-band-5-color, #8aadf4) !important; } #subtitleRoot br { @@ -439,93 +543,6 @@ body.platform-macos.layer-visible #subtitleRoot { background: transparent; } -body.layer-invisible #subtitleContainer { - background: transparent !important; - border: 0 !important; - padding: 0 !important; - border-radius: 0 !important; - position: relative; - z-index: 3; -} - -body.layer-invisible #subtitleRoot, -body.layer-invisible #subtitleRoot .word, -body.layer-invisible #subtitleRoot .c { - color: transparent !important; - text-shadow: none !important; - -webkit-text-stroke: 0 !important; - -webkit-text-fill-color: transparent !important; - background: transparent !important; - caret-color: transparent !important; - line-height: var(--invisible-sub-line-height, normal) !important; - font-kerning: auto; - letter-spacing: normal; - font-variant-ligatures: normal; - font-feature-settings: normal; - text-rendering: auto; -} - -body.layer-invisible #subtitleRoot br { - margin-bottom: 0 !important; -} - -body.layer-invisible #subtitleRoot .word:hover, -body.layer-invisible #subtitleRoot .c:hover, -body.layer-invisible #subtitleRoot.has-selection .word:hover, -body.layer-invisible #subtitleRoot.has-selection .c:hover { - background: transparent !important; -} - -body.layer-invisible #subtitleRoot::selection, -body.layer-invisible #subtitleRoot .word::selection, -body.layer-invisible #subtitleRoot .c::selection { - background: transparent !important; - color: transparent !important; -} - -body.layer-invisible.debug-invisible-visualization #subtitleRoot, -body.layer-invisible.debug-invisible-visualization #subtitleRoot .word, -body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { - color: #ed8796 !important; - -webkit-text-fill-color: #ed8796 !important; - -webkit-text-stroke: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important; - paint-order: stroke fill !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: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important; - paint-order: stroke fill !important; - text-shadow: none !important; -} - #secondarySubContainer { position: absolute; top: 40px; @@ -538,40 +555,6 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c { pointer-events: auto; } -body.layer-visible #secondarySubContainer, -body.layer-invisible #secondarySubContainer { - display: none !important; - pointer-events: none !important; -} - -body.layer-secondary #subtitleContainer, -body.layer-secondary .modal, -body.layer-secondary .overlay-error-toast { - display: none !important; - pointer-events: none !important; -} - -body.layer-secondary #overlay { - justify-content: flex-start; - align-items: stretch; -} - -body.layer-secondary #secondarySubContainer { - position: absolute; - inset: 0; - top: 0; - left: 0; - right: 0; - transform: none; - max-width: 100%; - border-radius: 0; - background: transparent; - padding: 8px 12px; - display: flex; - align-items: center; - justify-content: center; -} - body.layer-modal #subtitleContainer, body.layer-modal #secondarySubContainer { display: none !important; @@ -597,10 +580,6 @@ body.layer-modal #overlay { cursor: text; } -body.layer-secondary #secondarySubRoot { - max-width: 100%; -} - #secondarySubRoot:empty { display: none; } @@ -644,11 +623,7 @@ body.settings-modal-open #secondarySubContainer { opacity: 1; } -body.layer-secondary #secondarySubContainer.secondary-sub-hover { - padding: 8px 12px; - align-items: center; -} - +iframe.yomitan-popup, iframe[id^='yomitan-popup'] { pointer-events: auto !important; z-index: 2147483647 !important; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 871bef2..8015420 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -10,9 +10,9 @@ import { buildInvisibleTokenHoverRanges, computeWordClass, normalizeSubtitle, + sanitizeSubtitleHoverTokenColor, shouldRenderTokenizedSubtitle, } from './subtitle-render.js'; -import { resolveInvisibleLineHeight } from './positioning/invisible-layout-helpers.js'; function createToken(overrides: Partial): MergedToken { return { @@ -210,6 +210,17 @@ test('computeWordClass skips frequency class when rank is out of topX', () => { assert.equal(actual, 'word'); }); +test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => { + assert.equal(sanitizeSubtitleHoverTokenColor('#000000'), '#f4dbd6'); + assert.equal(sanitizeSubtitleHoverTokenColor('000000'), '#f4dbd6'); + assert.equal(sanitizeSubtitleHoverTokenColor('#0000'), '#f4dbd6'); +}); + +test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => { + assert.equal(sanitizeSubtitleHoverTokenColor('#ff00ff'), '#ff00ff'); + assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6'); +}); + test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => { const tokens = [ createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }), @@ -285,20 +296,16 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i ); }); -test('shouldRenderTokenizedSubtitle disables token rendering on invisible layer', () => { - assert.equal(shouldRenderTokenizedSubtitle(true, 5), false); -}); - -test('shouldRenderTokenizedSubtitle enables token rendering on visible layer when tokens exist', () => { - assert.equal(shouldRenderTokenizedSubtitle(false, 5), true); - assert.equal(shouldRenderTokenizedSubtitle(false, 0), false); +test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => { + assert.equal(shouldRenderTokenizedSubtitle(5), true); + assert.equal(shouldRenderTokenizedSubtitle(0), false); }); test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css'); const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); - const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath; + const cssPath = fs.existsSync(srcCssPath) ? srcCssPath : distCssPath; if (!fs.existsSync(cssPath)) { assert.fail( 'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.', @@ -330,31 +337,86 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { assert.match(block, /color:\s*var\(/); } - const invisibleBlock = extractClassBlock( - cssText, - 'body.layer-invisible #subtitleRoot', - ); - assert.match( - invisibleBlock, - /line-height:\s*var\(--invisible-sub-line-height,\s*normal\)\s*!important;/, - ); - const visibleMacBlock = extractClassBlock( cssText, 'body.platform-macos.layer-visible #subtitleRoot', ); assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/); assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/); -}); -test('invisible overlay uses looser line height on macOS for multi-line subtitles', () => { - assert.equal(resolveInvisibleLineHeight(1, true), '1.08'); - assert.equal(resolveInvisibleLineHeight(2, true), '1.5'); - assert.equal(resolveInvisibleLineHeight(3, true), '1.62'); -}); + const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot'); + assert.match( + subtitleRootBlock, + /--subtitle-hover-token-color:\s*#f4dbd6;/, + ); + assert.match( + subtitleRootBlock, + /--subtitle-hover-token-background-color:\s*rgba\(54,\s*58,\s*79,\s*0\.84\);/, + ); + assert.match(subtitleRootBlock, /-webkit-text-fill-color:\s*currentColor;/); -test('invisible overlay keeps default line height on non-macOS platforms', () => { - assert.equal(resolveInvisibleLineHeight(1, false), 'normal'); - assert.equal(resolveInvisibleLineHeight(2, false), 'normal'); - assert.equal(resolveInvisibleLineHeight(4, false), 'normal'); + const charBlock = extractClassBlock(cssText, '#subtitleRoot .c'); + assert.match(charBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/); + + const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word'); + assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/); + + assert.match( + cssText, + /#subtitleRoot \.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + ); + + const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover'); + assert.match( + coloredWordHoverBlock, + /background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/, + ); + assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/); + assert.match(coloredWordHoverBlock, /font-weight:\s*800;/); + assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/); + assert.doesNotMatch(coloredWordHoverBlock, /-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color/); + + const coloredWordSelectionBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known::selection'); + assert.match( + coloredWordSelectionBlock, + /color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/, + ); + assert.match( + coloredWordSelectionBlock, + /-webkit-text-fill-color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/, + ); + + const coloredCharHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known .c:hover'); + assert.match(coloredCharHoverBlock, /background:\s*transparent;/); + assert.match(coloredCharHoverBlock, /color:\s*inherit\s*!important;/); + + const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection'); + assert.match( + selectionBlock, + /background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/, + ); + assert.match(selectionBlock, /color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/); + assert.match( + selectionBlock, + /-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + ); + + const descendantSelectionBlock = extractClassBlock(cssText, '#subtitleRoot *::selection'); + assert.match( + descendantSelectionBlock, + /background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\)\s*!important;/, + ); + assert.match( + descendantSelectionBlock, + /color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + ); + assert.match( + descendantSelectionBlock, + /-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + ); + + assert.doesNotMatch( + cssText, + /body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i, + ); }); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 66592ce..e1b7768 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -15,11 +15,8 @@ export type InvisibleTokenHoverRange = { tokenIndex: number; }; -export function shouldRenderTokenizedSubtitle( - isInvisibleLayer: boolean, - tokenCount: number, -): boolean { - return !isInvisibleLayer && tokenCount > 0; +export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean { + return tokenCount > 0; } function isWhitespaceOnly(value: string): boolean { @@ -47,6 +44,23 @@ function sanitizeHexColor(value: unknown, fallback: string): string { : fallback; } +export function sanitizeSubtitleHoverTokenColor(value: unknown): string { + const sanitized = sanitizeHexColor(value, '#f4dbd6'); + const normalized = sanitized.replace(/^#/, '').toLowerCase(); + if (normalized === '000' || normalized === '0000' || normalized === '000000' || normalized === '00000000') { + return '#f4dbd6'; + } + return sanitized; +} + +function sanitizeSubtitleHoverTokenBackgroundColor(value: unknown): string { + if (typeof value !== 'string') { + return 'rgba(54, 58, 79, 0.84)'; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : 'rgba(54, 58, 79, 0.84)'; +} + const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = { enabled: false, topX: 1000, @@ -79,6 +93,54 @@ function sanitizeFrequencyBandedColors( ]; } +function applyInlineStyleDeclarations( + target: HTMLElement, + declarations: Record, + excludedKeys: ReadonlySet = new Set(), +): void { + for (const [key, value] of Object.entries(declarations)) { + if (excludedKeys.has(key)) { + continue; + } + if (value === null || value === undefined || typeof value === 'object') { + continue; + } + + const cssValue = String(value); + if (key.startsWith('-') || key.includes('-')) { + target.style.setProperty(key, cssValue); + if (key === '--webkit-text-stroke') { + target.style.setProperty('-webkit-text-stroke', cssValue); + } + continue; + } + + const styleTarget = target.style as unknown as Record; + styleTarget[key] = cssValue; + } +} + +function pickInlineStyleDeclarations( + declarations: Record, + includedKeys: ReadonlySet, +): Record { + const picked: Record = {}; + for (const [key, value] of Object.entries(declarations)) { + if (!includedKeys.has(key)) continue; + picked[key] = value; + } + return picked; +} + +const CONTAINER_STYLE_KEYS = new Set([ + 'background', + 'backgroundColor', + 'backdropFilter', + 'WebkitBackdropFilter', + 'webkitBackdropFilter', + '-webkit-backdrop-filter', +]); + function getFrequencyDictionaryClass( token: MergedToken, settings: FrequencyRenderSettings, @@ -337,11 +399,8 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void } export function createSubtitleRenderer(ctx: RendererContext) { - function renderSubtitle(data: SubtitleData | string): void { +function renderSubtitle(data: SubtitleData | string): void { ctx.dom.subtitleRoot.innerHTML = ''; - ctx.state.lastHoverSelectionKey = ''; - ctx.state.lastHoverSelectionNode = null; - ctx.state.lastHoveredTokenIndex = null; let text: string; let tokens: MergedToken[] | null; @@ -358,22 +417,8 @@ export function createSubtitleRenderer(ctx: RendererContext) { if (!text) return; - if (ctx.platform.isInvisibleLayer) { - // Keep natural kerning/shaping in invisible layer to match mpv glyph placement. - const normalizedInvisible = normalizeSubtitle(text, false); - ctx.state.currentInvisibleSubtitleLineCount = Math.max( - 1, - normalizedInvisible.split('\n').length, - ); - ctx.state.invisibleTokenHoverSourceText = normalizedInvisible; - ctx.state.invisibleTokenHoverRanges = - tokens && tokens.length > 0 ? buildInvisibleTokenHoverRanges(tokens, normalizedInvisible) : []; - renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible); - return; - } - const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks); - if (shouldRenderTokenizedSubtitle(ctx.platform.isInvisibleLayer, tokens?.length ?? 0) && tokens) { + if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) { renderWithTokens( ctx.dom.subtitleRoot, tokens, @@ -444,17 +489,30 @@ export function createSubtitleRenderer(ctx: RendererContext) { function applySubtitleStyle(style: SubtitleStyleConfig | null): void { if (!style) return; + const styleDeclarations = style as Record; + applyInlineStyleDeclarations( + ctx.dom.subtitleRoot, + styleDeclarations, + CONTAINER_STYLE_KEYS, + ); + applyInlineStyleDeclarations( + ctx.dom.subtitleContainer, + pickInlineStyleDeclarations(styleDeclarations, CONTAINER_STYLE_KEYS), + ); + if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily; if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`; - if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor; + if (style.fontColor) { + ctx.dom.subtitleRoot.style.color = style.fontColor; + } if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight; if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; - if (style.backgroundColor) { - ctx.dom.subtitleContainer.style.background = style.backgroundColor; - } - const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95'; const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6'; + const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor); + const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor( + style.hoverTokenBackgroundColor, + ); const jlptColors = { N1: ctx.state.jlptN1Color ?? '#ed8796', N2: ctx.state.jlptN2Color ?? '#f5a97f', @@ -476,6 +534,11 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.state.nPlusOneColor = nPlusOneColor; ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-hover-token-background-color', + hoverTokenBackgroundColor, + ); ctx.state.jlptN1Color = jlptColors.N1; ctx.state.jlptN2Color = jlptColors.N2; ctx.state.jlptN3Color = jlptColors.N3; @@ -551,6 +614,17 @@ export function createSubtitleRenderer(ctx: RendererContext) { const secondaryStyle = style.secondary; if (!secondaryStyle) return; + const secondaryStyleDeclarations = secondaryStyle as Record; + applyInlineStyleDeclarations( + ctx.dom.secondarySubRoot, + secondaryStyleDeclarations, + CONTAINER_STYLE_KEYS, + ); + applyInlineStyleDeclarations( + ctx.dom.secondarySubContainer, + pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS), + ); + if (secondaryStyle.fontFamily) { ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily; } @@ -566,9 +640,6 @@ export function createSubtitleRenderer(ctx: RendererContext) { if (secondaryStyle.fontStyle) { ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle; } - if (secondaryStyle.backgroundColor) { - ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor; - } } return { diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts index 10f1ce0..7134c03 100644 --- a/src/renderer/utils/platform.ts +++ b/src/renderer/utils/platform.ts @@ -1,40 +1,25 @@ -export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal'; +export type OverlayLayer = 'visible' | 'modal'; export type PlatformInfo = { overlayLayer: OverlayLayer; - isInvisibleLayer: boolean; - isSecondaryLayer: boolean; isModalLayer: boolean; isLinuxPlatform: boolean; isMacOSPlatform: boolean; shouldToggleMouseIgnore: boolean; - invisiblePositionEditToggleCode: string; - invisiblePositionStepPx: number; - invisiblePositionStepFastPx: number; }; export function resolvePlatformInfo(): PlatformInfo { const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); const queryLayer = new URLSearchParams(window.location.search).get('layer'); const overlayLayerFromQuery: OverlayLayer | null = - queryLayer === 'visible' || - queryLayer === 'invisible' || - queryLayer === 'secondary' || - queryLayer === 'modal' - ? queryLayer - : null; + queryLayer === 'visible' || queryLayer === 'modal' ? queryLayer : null; const overlayLayer: OverlayLayer = overlayLayerFromQuery ?? - (overlayLayerFromPreload === 'visible' || - overlayLayerFromPreload === 'invisible' || - overlayLayerFromPreload === 'secondary' || - overlayLayerFromPreload === 'modal' + (overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'modal' ? overlayLayerFromPreload : 'visible'); - const isInvisibleLayer = overlayLayer === 'invisible'; - const isSecondaryLayer = overlayLayer === 'secondary'; const isModalLayer = overlayLayer === 'modal'; const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); const isMacOSPlatform = @@ -42,14 +27,9 @@ export function resolvePlatformInfo(): PlatformInfo { return { overlayLayer, - isInvisibleLayer, - isSecondaryLayer, isModalLayer, isLinuxPlatform, isMacOSPlatform, - shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer, - invisiblePositionEditToggleCode: 'KeyP', - invisiblePositionStepPx: 1, - invisiblePositionStepFastPx: 4, + shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer, }; } diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index beeae4b..24d346b 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -19,15 +19,14 @@ export const IPC_CHANNELS = { refreshKnownWords: 'anki:refresh-known-words', kikuFieldGroupingRespond: 'kiku:field-grouping-respond', reportOverlayContentBounds: 'overlay-content-bounds:report', + overlayModalOpened: 'overlay:modal-opened', }, request: { getOverlayVisibility: 'get-overlay-visibility', getVisibleOverlayVisibility: 'get-visible-overlay-visibility', - getInvisibleOverlayVisibility: 'get-invisible-overlay-visibility', getCurrentSubtitle: 'get-current-subtitle', getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleAss: 'get-current-subtitle-ass', - getMpvSubtitleRenderMetrics: 'get-mpv-subtitle-render-metrics', getSubtitlePosition: 'get-subtitle-position', getSubtitleStyle: 'get-subtitle-style', getMecabStatus: 'get-mecab-status', @@ -57,9 +56,7 @@ export const IPC_CHANNELS = { subtitleSet: 'subtitle:set', subtitleVisibility: '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', diff --git a/src/types.ts b/src/types.ts index df59f63..1affa73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,8 +71,6 @@ export interface WindowGeometry { export interface SubtitlePosition { yPercent: number; - invisibleOffsetXPx?: number; - invisibleOffsetYPx?: number; } export interface SubtitleStyle { @@ -272,6 +270,7 @@ export interface SubtitleStyleConfig { enableJlpt?: boolean; preserveLineBreaks?: boolean; hoverTokenColor?: string; + hoverTokenBackgroundColor?: string; fontFamily?: string; fontSize?: number; fontColor?: string; @@ -309,7 +308,6 @@ export type FrequencyDictionaryMode = 'single' | 'banded'; export interface ShortcutsConfig { toggleVisibleOverlayGlobal?: string | null; - toggleInvisibleOverlayGlobal?: string | null; copySubtitle?: string | null; copySubtitleMultiple?: string | null; updateLastCardFromClipboard?: string | null; @@ -364,10 +362,6 @@ export interface DiscordPresenceConfig { debounceMs?: number; } -export interface InvisibleOverlayConfig { - startupVisibility?: 'platform-default' | 'visible' | 'hidden'; -} - export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off'; export interface YoutubeSubgenConfig { @@ -410,7 +404,6 @@ export interface Config { anilist?: AnilistConfig; jellyfin?: JellyfinConfig; discordPresence?: DiscordPresenceConfig; - invisibleOverlay?: InvisibleOverlayConfig; youtubeSubgen?: YoutubeSubgenConfig; immersionTracking?: ImmersionTrackingConfig; logging?: { @@ -540,7 +533,6 @@ export interface ResolvedConfig { updateIntervalMs: number; debounceMs: number; }; - invisibleOverlay: Required; youtubeSubgen: YoutubeSubgenConfig & { mode: YoutubeSubgenMode; whisperBin: string; @@ -630,7 +622,7 @@ export interface MpvSubtitleRenderMetrics { } | null; } -export type OverlayLayer = 'visible' | 'invisible'; +export type OverlayLayer = 'visible'; export interface OverlayContentRect { x: number; @@ -728,7 +720,7 @@ export interface SubtitleHoverTokenPayload { } export interface ElectronAPI { - getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | 'modal' | null; + getOverlayLayer: () => 'visible' | 'modal' | null; onSubtitle: (callback: (data: SubtitleData) => void) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; @@ -736,10 +728,7 @@ export interface ElectronAPI { getCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => Promise; getCurrentSubtitleAss: () => Promise; - getMpvSubtitleRenderMetrics: () => Promise; - onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void; onSubtitleAss: (callback: (assText: string) => void) => void; - onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; openYomitanSettings: () => void; getSubtitlePosition: () => Promise; @@ -781,8 +770,8 @@ export interface ElectronAPI { onOpenJimaku: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; + notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; - reportHoveredSubtitleToken: (tokenIndex: number | null) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; }