feat: bind overlay state to secondary subtitle mpv visibility

This commit is contained in:
2026-02-26 16:40:51 -08:00
parent 74554a30f0
commit 75442a4648
48 changed files with 1231 additions and 1070 deletions

View File

@@ -13,11 +13,11 @@
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
// ========================================== // ==========================================
// Visible Overlay Subtitle Binding // Visible Overlay Subtitle Binding (Legacy)
// Control whether visible overlay toggles also toggle MPV subtitle visibility. // Backward-compatible key. Runtime ignores this value.
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. // 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 // Texthooker Server
@@ -53,7 +53,6 @@
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting. "copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
@@ -68,16 +67,6 @@
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting. "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // 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) // Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults. // Extra keybindings that are merged with built-in defaults.
@@ -123,9 +112,11 @@
// Hot-reload: subtitle style changes apply live without restarting SubMiner. // Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ========================================== // ==========================================
"subtitleStyle": { "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 "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 "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. "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. "fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.

View File

@@ -78,7 +78,7 @@ src/
### Service Layer (`src/core/services/`) ### 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` - **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` - **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` - **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.ts # Facade export for positioning controller
positioning/ positioning/
controller.ts # Position controller orchestration controller.ts # Position controller orchestration
invisible-layout*.ts # Invisible layer layout computations
position-state.ts # Position state helpers position-state.ts # Position state helpers
handlers/ handlers/
keyboard.ts # Keybindings, chord handling, modal key routing keyboard.ts # Keybindings, chord handling, modal key routing
@@ -125,7 +124,7 @@ src/renderer/
## Flow Diagram ## 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 ```mermaid
flowchart LR flowchart LR
@@ -162,7 +161,7 @@ flowchart LR
subgraph Svc["Services — src/core/services/"] subgraph Svc["Services — src/core/services/"]
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc Overlay["Overlay Manager<br/>window · visibility · bridge"]:::svc
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
@@ -172,9 +171,7 @@ flowchart LR
Bridge(["preload.ts<br/>Electron IPC"]):::bridge Bridge(["preload.ts<br/>Electron IPC"]):::bridge
subgraph Rend["Renderer — src/renderer/"] subgraph Rend["Renderer — src/renderer/"]
Visible["Visible window<br/>Yomitan lookups"]:::rend Overlay["Main overlay window<br/>primary + secondary subtitles"]:::rend
Invisible["Invisible window<br/>mpv positioning"]:::rend
Secondary["Secondary window<br/>subtitle bar"]:::rend
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
end end
@@ -193,10 +190,8 @@ flowchart LR
DiscordExt <-->|"RPC"| Integrations DiscordExt <-->|"RPC"| Integrations
Overlay & Mining --> Bridge Overlay & Mining --> Bridge
Bridge --> Visible Bridge --> Overlay
Bridge --> Invisible Overlay --> UI
Bridge --> Secondary
Visible & Invisible & Secondary --> UI
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5 style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
style Svc 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. - **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. - **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`. - **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. - **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. - **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 ```mermaid
@@ -298,14 +293,10 @@ flowchart LR
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
VisWin --> Warmups MainWin --> Warmups
InvWin --> Warmups
SecWin --> Warmups
Shortcuts --> Warmups Shortcuts --> Warmups
Warmups["Background<br/>warmups"]:::phase Warmups["Background<br/>warmups"]:::phase
@@ -330,7 +321,7 @@ flowchart LR
ExtEvt["Shortcuts · config hot-reload"]:::runtime ExtEvt["Shortcuts · config hot-reload"]:::runtime
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
end end
WarmupGroup --> Loop WarmupGroup --> Loop

View File

@@ -38,8 +38,8 @@ features:
- icon: - icon:
src: /assets/dual-layer.svg src: /assets/dual-layer.svg
alt: Dual layer icon alt: Dual layer icon
title: Three-Plane Overlay Stack title: Unified Overlay Stack
details: Secondary context plane + visible interactive layer + invisible interaction plane, each with independent behavior and startup state. details: Primary interactive subtitle layer with a built-in secondary context bar, all in one overlay window.
- icon: - icon:
src: /assets/highlight.svg src: /assets/highlight.svg
alt: Highlight icon alt: Highlight icon

View File

@@ -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. 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: 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 - Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
- **N+1 highlighting** — known words from your Anki deck are visually highlighted - **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. Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
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.
## Looking Up Words ## Looking Up Words
@@ -73,10 +59,10 @@ Toggle controls:
3. Yomitan detects the text selection and opens its popup with dictionary results. 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. 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. 1. Subtitles are rendered directly in the overlay.
2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text. 2. Click on any word in the subtitle.
3. On macOS, word selection happens automatically on hover. 3. On macOS, word selection happens automatically on hover.
4. Yomitan popup appears for lookup and card creation. 4. Yomitan popup appears for lookup and card creation.

View File

@@ -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.

View File

@@ -21,8 +21,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "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: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: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: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/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: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: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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",

View File

@@ -77,7 +77,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (isObject(src.shortcuts)) { if (isObject(src.shortcuts)) {
const shortcutKeys = [ const shortcutKeys = [
'toggleVisibleOverlayGlobal', 'toggleVisibleOverlayGlobal',
'toggleInvisibleOverlayGlobal',
'copySubtitle', 'copySubtitle',
'copySubtitleMultiple', 'copySubtitleMultiple',
'updateLastCardFromClipboard', '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 (isObject(src.secondarySub)) {
if (Array.isArray(src.secondarySub.secondarySubLanguages)) { if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter(

View File

@@ -23,7 +23,6 @@ export {
export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle'; export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle';
export { cycleSecondarySubMode } from './subtitle-position'; export { cycleSecondarySubMode } from './subtitle-position';
export { export {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime, isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig, shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility, shouldBindVisibleOverlayToMpvSubVisibility,
@@ -59,14 +58,12 @@ export {
createOverlayWindow, createOverlayWindow,
enforceOverlayLayerOrder, enforceOverlayLayerOrder,
ensureOverlayWindowLevel, ensureOverlayWindowLevel,
syncOverlayWindowLayer,
updateOverlayWindowBounds, updateOverlayWindowBounds,
} from './overlay-window'; } from './overlay-window';
export { initializeOverlayRuntime } from './overlay-runtime-init'; export { initializeOverlayRuntime } from './overlay-runtime-init';
export { export {
setInvisibleOverlayVisible,
setVisibleOverlayVisible, setVisibleOverlayVisible,
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility, updateVisibleOverlayVisibility,
} from './overlay-visibility'; } from './overlay-visibility';
export { export {
@@ -76,6 +73,7 @@ export {
replayCurrentSubtitleRuntime, replayCurrentSubtitleRuntime,
resolveCurrentAudioStreamIndex, resolveCurrentAudioStreamIndex,
sendMpvCommandRuntime, sendMpvCommandRuntime,
setMpvSecondarySubVisibilityRuntime,
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
showMpvOsdRuntime, showMpvOsdRuntime,
} from './mpv'; } from './mpv';

View File

@@ -60,6 +60,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-use-margins', 'sub-use-margins',
'pause', 'pause',
'media-title', 'media-title',
'secondary-sub-visibility',
'sub-visibility',
]; ];
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [

View File

@@ -119,6 +119,38 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); 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 () => { test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
const { deps, state } = createDeps(); const { deps, state } = createDeps();

View File

@@ -48,6 +48,7 @@ export interface MpvProtocolHandleMessageDeps {
}; };
getSubtitleMetrics: () => MpvSubtitleRenderMetrics; getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility?: () => boolean;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
emitSubtitleAssChange: (payload: { text: string }) => void; emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
@@ -216,6 +217,22 @@ export async function dispatchMpvProtocolMessage(
deps.emitSubtitleMetricsChange({ deps.emitSubtitleMetricsChange({
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow), 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') { } else if (msg.name === 'sub-use-margins') {
deps.emitSubtitleMetricsChange({ deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins), subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),

View File

@@ -306,6 +306,32 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
assert.equal(hasPathRequest, true); 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 () => { test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
const commands: unknown[] = []; const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());

View File

@@ -44,6 +44,7 @@ export interface MpvRuntimeClientLike {
replayCurrentSubtitle?: () => void; replayCurrentSubtitle?: () => void;
playNextSubtitle?: () => void; playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void; setSubVisibility?: (visible: boolean) => void;
setSecondarySubVisibility?: (visible: boolean) => void;
} }
export function showMpvOsdRuntime( export function showMpvOsdRuntime(
@@ -84,6 +85,14 @@ export function setMpvSubVisibilityRuntime(
mpvClient.setSubVisibility(visible); 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 { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
export interface MpvIpcClientProtocolDeps { export interface MpvIpcClientProtocolDeps {
@@ -181,8 +190,6 @@ export class MpvIpcClient implements MpvClient {
setTimeout(() => { setTimeout(() => {
this.deps.setOverlayVisible(true); this.deps.setOverlayVisible(true);
}, 100); }, 100);
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
} }
this.firstConnection = false; this.firstConnection = false;
@@ -290,6 +297,8 @@ export class MpvIpcClient implements MpvClient {
getResolvedConfig: () => this.deps.getResolvedConfig(), getResolvedConfig: () => this.deps.getResolvedConfig(),
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics, getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(), isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
this.deps.shouldBindVisibleOverlayToMpvSubVisibility(),
emitSubtitleChange: (payload) => { emitSubtitleChange: (payload) => {
this.emit('subtitle-change', payload); this.emit('subtitle-change', payload);
}, },
@@ -488,7 +497,7 @@ export class MpvIpcClient implements MpvClient {
this.previousSecondarySubVisibility = null; this.previousSecondarySubVisibility = null;
} }
private setSecondarySubVisibility(visible: boolean): void { setSecondarySubVisibility(visible: boolean): void {
this.send({ this.send({
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'], command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
}); });

View File

@@ -9,11 +9,8 @@ import {
test('overlay manager initializes with empty windows and hidden overlays', () => { test('overlay manager initializes with empty windows and hidden overlays', () => {
const manager = createOverlayManager(); const manager = createOverlayManager();
assert.equal(manager.getMainWindow(), null); assert.equal(manager.getMainWindow(), null);
assert.equal(manager.getInvisibleWindow(), null);
assert.equal(manager.getSecondaryWindow(), null);
assert.equal(manager.getModalWindow(), null); assert.equal(manager.getModalWindow(), null);
assert.equal(manager.getVisibleOverlayVisible(), false); assert.equal(manager.getVisibleOverlayVisible(), false);
assert.equal(manager.getInvisibleOverlayVisible(), false);
assert.deepEqual(manager.getOverlayWindows(), []); assert.deepEqual(manager.getOverlayWindows(), []);
}); });
@@ -22,28 +19,17 @@ test('overlay manager stores window references and returns stable window order',
const visibleWindow = { const visibleWindow = {
isDestroyed: () => false, isDestroyed: () => false,
} as unknown as Electron.BrowserWindow; } 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 = { const modalWindow = {
isDestroyed: () => false, isDestroyed: () => false,
} as unknown as Electron.BrowserWindow; } as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow); manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow(modalWindow); manager.setModalWindow(modalWindow);
assert.equal(manager.getMainWindow(), visibleWindow); assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
assert.equal(manager.getModalWindow(), modalWindow); assert.equal(manager.getModalWindow(), modalWindow);
assert.equal(manager.getOverlayWindow('visible'), visibleWindow); assert.equal(manager.getOverlayWindow(), visibleWindow);
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow); assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
}); });
test('overlay manager excludes destroyed windows', () => { test('overlay manager excludes destroyed windows', () => {
@@ -51,26 +37,18 @@ test('overlay manager excludes destroyed windows', () => {
manager.setMainWindow({ manager.setMainWindow({
isDestroyed: () => true, isDestroyed: () => true,
} as unknown as Electron.BrowserWindow); } 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({ manager.setModalWindow({
isDestroyed: () => false, isDestroyed: () => false,
} as unknown as Electron.BrowserWindow); } as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1); assert.equal(manager.getOverlayWindows().length, 0);
}); });
test('overlay manager stores visibility state', () => { test('overlay manager stores visibility state', () => {
const manager = createOverlayManager(); const manager = createOverlayManager();
manager.setVisibleOverlayVisible(true); manager.setVisibleOverlayVisible(true);
manager.setInvisibleOverlayVisible(true);
assert.equal(manager.getVisibleOverlayVisible(), true); assert.equal(manager.getVisibleOverlayVisible(), true);
assert.equal(manager.getInvisibleOverlayVisible(), true);
}); });
test('overlay manager broadcasts to non-destroyed windows', () => { 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; } 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.setMainWindow(aliveWindow);
manager.setInvisibleWindow(deadWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow({ manager.setModalWindow({
isDestroyed: () => false, isDestroyed: () => false,
webContents: { send: () => {} }, webContents: { send: () => {} },
} as unknown as Electron.BrowserWindow); } as unknown as Electron.BrowserWindow);
manager.broadcastToOverlayWindows('x', 1, 'a'); manager.broadcastToOverlayWindows('x', 1, 'a');
assert.deepEqual(calls, [ assert.deepEqual(calls, [['x', 1, 'a']]);
['x', 1, 'a'],
['x', 1, 'a'],
]);
}); });
test('overlay manager applies bounds by layer', () => { test('overlay manager applies bounds for main and modal windows', () => {
const manager = createOverlayManager(); const manager = createOverlayManager();
const visibleCalls: Electron.Rectangle[] = []; const visibleCalls: Electron.Rectangle[] = [];
const invisibleCalls: Electron.Rectangle[] = [];
const visibleWindow = { const visibleWindow = {
isDestroyed: () => false, isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => { setBounds: (bounds: Electron.Rectangle) => {
visibleCalls.push(bounds); visibleCalls.push(bounds);
}, },
} as unknown as Electron.BrowserWindow; } 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 modalCalls: Electron.Rectangle[] = [];
const modalWindow = { const modalWindow = {
isDestroyed: () => false, isDestroyed: () => false,
@@ -144,28 +89,14 @@ test('overlay manager applies bounds by layer', () => {
}, },
} as unknown as Electron.BrowserWindow; } as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow); manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setSecondaryWindow(secondaryWindow);
manager.setModalWindow(modalWindow); manager.setModalWindow(modalWindow);
manager.setOverlayWindowBounds('visible', { manager.setOverlayWindowBounds({
x: 10, x: 10,
y: 20, y: 20,
width: 30, width: 30,
height: 40, 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({ manager.setModalWindowBounds({
x: 80, x: 80,
y: 90, 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(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 }]); 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[][] = []; const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntime( broadcastRuntimeOptionsChangedRuntime(
() => [], () => [],
@@ -196,14 +123,8 @@ test('runtime-option and debug broadcasts use expected channels', () => {
(enabled) => { (enabled) => {
state = enabled; state = enabled;
}, },
(channel, ...args) => {
broadcasts.push([channel, ...args]);
},
); );
assert.equal(changed, true); assert.equal(changed, true);
assert.equal(state, true); assert.equal(state, true);
assert.deepEqual(broadcasts, [ assert.deepEqual(broadcasts, [['runtime-options:changed', []]]);
['runtime-options:changed', []],
['overlay-debug-visualization:set', true],
]);
}); });

View File

@@ -2,60 +2,37 @@ import type { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types'; import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window'; import { updateOverlayWindowBounds } from './overlay-window';
type OverlayLayer = 'visible' | 'invisible';
export interface OverlayManager { export interface OverlayManager {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
setMainWindow: (window: BrowserWindow | null) => void; setMainWindow: (window: BrowserWindow | null) => void;
getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void;
getSecondaryWindow: () => BrowserWindow | null;
setSecondaryWindow: (window: BrowserWindow | null) => void;
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
setModalWindow: (window: BrowserWindow | null) => void; setModalWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; getOverlayWindow: () => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void; setModalWindowBounds: (geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean;
setInvisibleOverlayVisible: (visible: boolean) => void;
getOverlayWindows: () => BrowserWindow[]; getOverlayWindows: () => BrowserWindow[];
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
} }
export function createOverlayManager(): OverlayManager { export function createOverlayManager(): OverlayManager {
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let invisibleWindow: BrowserWindow | null = null;
let secondaryWindow: BrowserWindow | null = null;
let modalWindow: BrowserWindow | null = null; let modalWindow: BrowserWindow | null = null;
let visibleOverlayVisible = false; let visibleOverlayVisible = false;
let invisibleOverlayVisible = false;
return { return {
getMainWindow: () => mainWindow, getMainWindow: () => mainWindow,
setMainWindow: (window) => { setMainWindow: (window) => {
mainWindow = window; mainWindow = window;
}, },
getInvisibleWindow: () => invisibleWindow,
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
getSecondaryWindow: () => secondaryWindow,
setSecondaryWindow: (window) => {
secondaryWindow = window;
},
getModalWindow: () => modalWindow, getModalWindow: () => modalWindow,
setModalWindow: (window) => { setModalWindow: (window) => {
modalWindow = window; modalWindow = window;
}, },
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow), getOverlayWindow: () => mainWindow,
setOverlayWindowBounds: (layer, geometry) => { setOverlayWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow); updateOverlayWindowBounds(geometry, mainWindow);
},
setSecondaryWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, secondaryWindow);
}, },
setModalWindowBounds: (geometry) => { setModalWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, modalWindow); updateOverlayWindowBounds(geometry, modalWindow);
@@ -64,36 +41,12 @@ export function createOverlayManager(): OverlayManager {
setVisibleOverlayVisible: (visible) => { setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible; visibleOverlayVisible = visible;
}, },
getInvisibleOverlayVisible: () => invisibleOverlayVisible,
setInvisibleOverlayVisible: (visible) => {
invisibleOverlayVisible = visible;
},
getOverlayWindows: () => { getOverlayWindows: () => {
const windows: BrowserWindow[] = []; return mainWindow && !mainWindow.isDestroyed() ? [mainWindow] : [];
if (mainWindow && !mainWindow.isDestroyed()) {
windows.push(mainWindow);
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
return windows;
}, },
broadcastToOverlayWindows: (channel, ...args) => { broadcastToOverlayWindows: (channel, ...args) => {
const windows: BrowserWindow[] = [];
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
windows.push(mainWindow); mainWindow.webContents.send(channel, ...args);
}
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
windows.push(invisibleWindow);
}
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
windows.push(secondaryWindow);
}
for (const window of windows) {
window.webContents.send(channel, ...args);
} }
}, },
}; };
@@ -110,10 +63,8 @@ export function setOverlayDebugVisualizationEnabledRuntime(
currentEnabled: boolean, currentEnabled: boolean,
nextEnabled: boolean, nextEnabled: boolean,
setState: (enabled: boolean) => void, setState: (enabled: boolean) => void,
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
): boolean { ): boolean {
if (currentEnabled === nextEnabled) return false; if (currentEnabled === nextEnabled) return false;
setState(nextEnabled); setState(nextEnabled);
broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled);
return true; return true;
} }

View File

@@ -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,
},
};
}

View File

@@ -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);
});

View File

@@ -4,8 +4,25 @@ import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
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( export function updateOverlayWindowBounds(
geometry: WindowGeometry, geometry: WindowGeometry,
@@ -32,14 +49,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
export function enforceOverlayLayerOrder(options: { export function enforceOverlayLayerOrder(options: {
visibleOverlayVisible: boolean; visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: BrowserWindow | null; mainWindow: BrowserWindow | null;
invisibleWindow: BrowserWindow | null;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
}): void { }): void {
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return; if (!options.visibleOverlayVisible) return;
if (!options.mainWindow || options.mainWindow.isDestroyed()) return; if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
options.ensureOverlayWindowLevel(options.mainWindow); options.ensureOverlayWindowLevel(options.mainWindow);
options.mainWindow.moveTop(); options.mainWindow.moveTop();
@@ -49,7 +63,6 @@ export function createOverlayWindow(
kind: OverlayWindowKind, kind: OverlayWindowKind,
options: { options: {
isDev: boolean; isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
@@ -83,16 +96,7 @@ export function createOverlayWindow(
}); });
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);
loadOverlayWindowLayer(window, kind);
const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
window
.loadFile(htmlPath, {
query: { layer: kind },
})
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error('Page failed to load:', 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', () => { window.webContents.on('did-finish-load', () => {
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
window.webContents.send(
'overlay-debug-visualization:set',
options.overlayDebugVisualizationEnabled,
);
}); });
if (kind === 'visible') { if (kind === 'visible') {
@@ -140,3 +140,9 @@ export function createOverlayWindow(
return window; return window;
} }
export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): void {
if (window.isDestroyed()) return;
if (overlayWindowLayerByInstance.get(window) === layer) return;
loadOverlayWindowLayer(window, layer);
}

View File

@@ -218,7 +218,6 @@ import {
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
createBuildEnforceOverlayLayerOrderMainDepsHandler, createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler, createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler, createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
createOverlayWindowRuntimeHandlers, createOverlayWindowRuntimeHandlers,
createOverlayRuntimeBootstrapHandlers, createOverlayRuntimeBootstrapHandlers,
@@ -234,7 +233,6 @@ import {
createSetOverlayDebugVisualizationEnabledHandler, createSetOverlayDebugVisualizationEnabledHandler,
createEnforceOverlayLayerOrderHandler, createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler, createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler, createUpdateVisibleOverlayBoundsHandler,
createLoadSubtitlePositionHandler, createLoadSubtitlePositionHandler,
createSaveSubtitlePositionHandler, createSaveSubtitlePositionHandler,
@@ -356,16 +354,16 @@ import {
runStartupBootstrapRuntime, runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
sendMpvCommandRuntime, sendMpvCommandRuntime,
setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore, setMpvSecondarySubVisibilityRuntime,
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
syncOverlayWindowLayer,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore, setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
showMpvOsdRuntime, showMpvOsdRuntime,
tokenizeSubtitle as tokenizeSubtitleCore, tokenizeSubtitle as tokenizeSubtitleCore,
triggerFieldGrouping as triggerFieldGroupingCore, triggerFieldGrouping as triggerFieldGroupingCore,
updateLastCardFromClipboard as updateLastCardFromClipboardCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services'; } from './core/services';
import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import { import {
guessAnilistMediaInfo, guessAnilistMediaInfo,
@@ -376,7 +374,10 @@ import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry'; 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 { import {
composeAnilistSetupHandlers, composeAnilistSetupHandlers,
composeAnilistTrackingHandlers, composeAnilistTrackingHandlers,
@@ -644,7 +645,6 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
}); });
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getModalWindow: () => overlayManager.getModalWindow(), getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => createModalWindow(), createModalWindow: () => createModalWindow(),
getModalGeometry: () => getCurrentOverlayGeometry(), getModalGeometry: () => getCurrentOverlayGeometry(),
@@ -725,13 +725,70 @@ async function initializeDiscordPresenceService(): Promise<void> {
await appState.discordPresenceService.start(); await appState.discordPresenceService.start();
publishDiscordPresence(); publishDiscordPresence();
} }
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({ const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getCurrentSubtitleData: () => appState.currentSubtitleData, getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex, setSavedSubVisibility: (visible) => {
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision, appState.overlaySavedMpvSubVisibility = visible;
getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null, },
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({ const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
@@ -766,7 +823,6 @@ const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMa
const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({ const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
platform: process.platform,
defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference,
defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults,
defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl, defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl,
@@ -801,15 +857,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
return await tokenizeSubtitle(text); return await tokenizeSubtitle(text);
}, },
emitSubtitle: (payload) => { emitSubtitle: (payload) => {
const previousSubtitleText = appState.currentSubtitleData?.text ?? null;
const nextSubtitleText = payload?.text ?? null;
const subtitleChanged = previousSubtitleText !== nextSubtitleText;
appState.currentSubtitleData = payload; appState.currentSubtitleData = payload;
if (subtitleChanged) {
appState.hoveredSubtitleTokenIndex = null;
appState.hoveredSubtitleRevision += 1;
applyHoveredTokenOverlay();
}
broadcastToOverlayWindows('subtitle:set', payload); broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, { subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
@@ -850,7 +898,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
copySubtitle: () => { copySubtitle: () => {
copyCurrentSubtitle(); copyCurrentSubtitle();
}, },
toggleSecondarySubMode: () => cycleSecondarySubMode(), toggleSecondarySubMode: () => handleCycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(), updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(), triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
@@ -899,8 +947,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
refreshGlobalAndOverlayShortcuts(); refreshGlobalAndOverlayShortcuts();
}, },
setSecondarySubMode: (mode) => { setSecondarySubMode: (mode) => {
appState.secondarySubMode = mode; setSecondarySubMode(mode);
syncSecondaryOverlayWindowVisibility();
}, },
broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload); broadcastToOverlayWindows(channel, payload);
@@ -1023,9 +1070,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({ createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
getResolver: () => getFieldGroupingResolver(), getResolver: () => getFieldGroupingResolver(),
setResolver: (resolver) => setFieldGroupingResolver(resolver), setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => getRestoreVisibleOverlayOnModalClose: () =>
@@ -1067,26 +1112,40 @@ const mediaRuntime = createMediaRuntimeService(
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({ createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown; appState.trackerNotReadyWarningShown = shown;
}, },
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel: (window) => {
ensureOverlayWindowLevel(window); ensureOverlayWindowLevel(window);
}, },
syncPrimaryOverlayWindowLayer: (layer) => {
syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => { enforceOverlayLayerOrder: () => {
enforceOverlayLayerOrder(); enforceOverlayLayerOrder();
}, },
syncOverlayShortcuts: () => { syncOverlayShortcuts: () => {
overlayShortcutsRuntime.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) => { setCurrentEnabled: (next) => {
appState.overlayDebugVisualizationEnabled = next; appState.overlayDebugVisualizationEnabled = next;
}, },
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
}); });
const setOverlayDebugVisualizationEnabledMainDeps = const setOverlayDebugVisualizationEnabledMainDeps =
buildSetOverlayDebugVisualizationEnabledMainDepsHandler(); buildSetOverlayDebugVisualizationEnabledMainDepsHandler();
@@ -1826,6 +1884,9 @@ const {
destroyTray: () => destroyTray(), destroyTray: () => destroyTray(),
stopConfigHotReload: () => configHotReloadRuntime.stop(), stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibilityForInvisibleOverlay: () => {
restoreOverlayMpvSubtitles({ respectVisibleOverlayBinding: false });
},
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => subtitleWsService.stop(), stopSubtitleWebsocket: () => subtitleWsService.stop(),
stopTexthookerService: () => texthookerService.stop(), stopTexthookerService: () => texthookerService.stop(),
@@ -1870,14 +1931,11 @@ const {
createMainWindow: () => { createMainWindow: () => {
createMainWindow(); createMainWindow();
}, },
createInvisibleWindow: () => {
createInvisibleWindow();
},
updateVisibleOverlayVisibility: () => { updateVisibleOverlayVisibility: () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
updateInvisibleOverlayVisibility: () => { syncOverlayMpvSubtitleSuppression: () => {
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); syncOverlayMpvSubtitleSuppression();
}, },
}, },
}); });
@@ -1934,8 +1992,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
); );
}, },
setSecondarySubMode: (mode: SecondarySubMode) => { setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode; setSecondarySubMode(mode);
syncSecondaryOverlayWindowVisibility();
}, },
defaultSecondarySubMode: 'hover', defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
@@ -2124,6 +2181,9 @@ const {
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
mediaRuntime.updateCurrentMediaPath(path); mediaRuntime.updateCurrentMediaPath(path);
}, },
restoreMpvSubVisibilityForInvisibleOverlay: () => {
restoreOverlayMpvSubtitles({ respectVisibleOverlayBinding: false });
},
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => { resetAnilistMediaTracking: (mediaKey) => {
resetAnilistMediaTracking(mediaKey); resetAnilistMediaTracking(mediaKey);
@@ -2149,6 +2209,9 @@ const {
updateSubtitleRenderMetrics: (patch) => { updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>); updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
}, },
syncOverlayMpvSubtitleSuppression: () => {
syncOverlayMpvSubtitleSuppression();
},
}, },
mpvClientRuntimeServiceFactoryMainDeps: { mpvClientRuntimeServiceFactoryMainDeps: {
createClient: MpvIpcClient, createClient: MpvIpcClient,
@@ -2170,8 +2233,8 @@ const {
appState.mpvSubtitleRenderMetrics = metrics; appState.mpvSubtitleRenderMetrics = metrics;
}, },
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: (metrics) => { broadcastMetrics: () => {
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics); // no renderer consumer for subtitle render metrics updates at present
}, },
}, },
tokenizer: { tokenizer: {
@@ -2276,52 +2339,21 @@ function getCurrentOverlayGeometry(): WindowGeometry {
return getOverlayGeometryFallback(); return getOverlayGeometryFallback();
} }
function syncSecondaryOverlayWindowVisibility(): void { function applyOverlayRegions(geometry: WindowGeometry): 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 {
lastOverlayWindowGeometry = geometry; lastOverlayWindowGeometry = geometry;
const regions = splitOverlayGeometryForSecondaryBar(geometry); overlayManager.setOverlayWindowBounds(geometry);
overlayManager.setOverlayWindowBounds(layer, regions.primary);
overlayManager.setSecondaryWindowBounds(regions.secondary);
overlayManager.setModalWindowBounds(geometry); overlayManager.setModalWindowBounds(geometry);
syncSecondaryOverlayWindowVisibility();
} }
const buildUpdateVisibleOverlayBoundsMainDepsHandler = const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
}); });
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
updateVisibleOverlayBoundsMainDeps, updateVisibleOverlayBoundsMainDeps,
); );
const buildUpdateInvisibleOverlayBoundsMainDepsHandler =
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
});
const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler();
const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler(
updateInvisibleOverlayBoundsMainDeps,
);
const buildEnsureOverlayWindowLevelMainDepsHandler = const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({ createBuildEnsureOverlayWindowLevelMainDepsHandler({
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
@@ -2331,21 +2363,23 @@ const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
ensureOverlayWindowLevelMainDeps, ensureOverlayWindowLevelMainDeps,
); );
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
syncOverlayWindowLayer(mainWindow, layer);
}
const buildEnforceOverlayLayerOrderMainDepsHandler = const buildEnforceOverlayLayerOrderMainDepsHandler =
createBuildEnforceOverlayLayerOrderMainDepsHandler({ createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: (params) => enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({ enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible, visibleOverlayVisible: params.visibleOverlayVisible,
invisibleOverlayVisible: params.invisibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null, mainWindow: params.mainWindow as BrowserWindow | null,
invisibleWindow: params.invisibleWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel: (window) =>
params.ensureOverlayWindowLevel(window as BrowserWindow), params.ensureOverlayWindowLevel(window as BrowserWindow),
}), }),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
}); });
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
@@ -2361,7 +2395,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
} }
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow { function createOverlayWindow(kind: 'visible' | 'modal'): BrowserWindow {
return createOverlayWindowHandler(kind); return createOverlayWindowHandler(kind);
} }
@@ -2375,25 +2409,9 @@ function createModalWindow(): BrowserWindow {
return window; 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 { function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler(); return createMainWindowHandler();
createSecondaryWindow();
return window;
} }
function createInvisibleWindow(): BrowserWindow {
return createInvisibleWindowHandler();
}
function resolveTrayIconPath(): string | null { function resolveTrayIconPath(): string | null {
return resolveTrayIconPathHandler(); return resolveTrayIconPathHandler();
} }
@@ -2412,6 +2430,7 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler(); initializeOverlayRuntimeHandler();
syncOverlayMpvSubtitleSuppression();
} }
function openYomitanSettings(): void { function openYomitanSettings(): void {
@@ -2441,7 +2460,6 @@ const {
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore, registerGlobalShortcutsCore,
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
isDev, isDev,
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
@@ -2495,8 +2513,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: { cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => appState.secondarySubMode, getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => { setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode; setSecondarySubMode(mode);
syncSecondaryOverlayWindowVisibility();
}, },
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => { setLastSecondarySubToggleAtMs: (timestampMs: number) => {
@@ -2510,6 +2527,15 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
}); });
function setSecondarySubMode(mode: SecondarySubMode): void {
appState.secondarySubMode = mode;
syncOverlayMpvSubtitleSuppression();
}
function handleCycleSecondarySubMode(): void {
cycleSecondarySubMode();
}
async function triggerSubsyncFromConfig(): Promise<void> { async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig(); await subsyncRuntime.triggerFromConfig();
} }
@@ -2613,9 +2639,7 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler(
); );
const { const {
setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, setVisibleOverlayVisible: setVisibleOverlayVisibleHandler,
setInvisibleOverlayVisible: setInvisibleOverlayVisibleHandler,
toggleVisibleOverlay: toggleVisibleOverlayHandler, toggleVisibleOverlay: toggleVisibleOverlayHandler,
toggleInvisibleOverlay: toggleInvisibleOverlayHandler,
setOverlayVisible: setOverlayVisibleHandler, setOverlayVisible: setOverlayVisibleHandler,
toggleOverlay: toggleOverlayHandler, toggleOverlay: toggleOverlayHandler,
} = createOverlayVisibilityRuntime({ } = createOverlayVisibilityRuntime({
@@ -2625,29 +2649,8 @@ const {
overlayManager.setVisibleOverlayVisible(nextVisible); overlayManager.setVisibleOverlayVisible(nextVisible);
}, },
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), 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(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
}); });
const buildHandleOverlayModalClosedMainDepsHandler = const buildHandleOverlayModalClosedMainDepsHandler =
@@ -2707,10 +2710,8 @@ const {
showMpvOsd: (text: string) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
}, },
mainDeps: { mainDeps: {
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
focusMainWindow: () => { focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
@@ -2721,13 +2722,15 @@ const {
onOverlayModalClosed: (modal) => { onOverlayModalClosed: (modal) => {
handleOverlayModalClosed(modal); handleOverlayModalClosed(modal);
}, },
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(), quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => { getSubtitleStyle: () => {
const resolvedConfig = getResolvedConfig(); const resolvedConfig = getResolvedConfig();
@@ -2744,9 +2747,6 @@ const {
reportOverlayContentBounds: (payload: unknown) => { reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload); overlayContentMeasurementStore.report(payload);
}, },
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
reportHoveredSubtitleToken(tokenIndex);
},
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
clearAnilistToken: () => anilistStateRuntime.clearTokenState(), clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
openAnilistSetup: () => openAnilistSetupWindow(), openAnilistSetup: () => openAnilistSetupWindow(),
@@ -2800,9 +2800,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
showMpvOsd: (text: string) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(), copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(), mineSentenceCard: () => mineSentenceCard(),
@@ -2821,7 +2819,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => app.quit(), stopApp: () => app.quit(),
@@ -2835,40 +2833,29 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
const { const {
createOverlayWindow: createOverlayWindowHandler, createOverlayWindow: createOverlayWindowHandler,
createMainWindow: createMainWindowHandler, createMainWindow: createMainWindowHandler,
createInvisibleWindow: createInvisibleWindowHandler,
createSecondaryWindow: createSecondaryWindowHandler,
createModalWindow: createModalWindowHandler, createModalWindow: createModalWindowHandler,
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({ } = createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev, isDev,
getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) => isOverlayVisible: (windowKind) =>
windowKind === 'visible' windowKind === 'visible'
? overlayManager.getVisibleOverlayVisible() ? overlayManager.getVisibleOverlayVisible()
: windowKind === 'invisible' : false,
? overlayManager.getInvisibleOverlayVisible()
: false,
tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => { onWindowClosed: (windowKind) => {
if (windowKind === 'visible') { if (windowKind === 'visible') {
overlayManager.setMainWindow(null); overlayManager.setMainWindow(null);
} else if (windowKind === 'invisible') {
overlayManager.setInvisibleWindow(null);
} else if (windowKind === 'secondary') {
overlayManager.setSecondaryWindow(null);
} else { } else {
overlayManager.setModalWindow(null); overlayManager.setModalWindow(null);
} }
}, },
}, },
setMainWindow: (window) => overlayManager.setMainWindow(window), setMainWindow: (window) => overlayManager.setMainWindow(window),
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window), setModalWindow: (window) => overlayManager.setModalWindow(window),
}); });
const { const {
@@ -2948,24 +2935,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
appState, appState,
overlayManager: { overlayManager: {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
}, },
overlayVisibilityRuntime: { overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(), overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
}, },
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
}, },
getInitialInvisibleOverlayVisibility: () =>
configDerivedRuntime.getInitialInvisibleOverlayVisibility(),
createMainWindow: () => createMainWindow(), createMainWindow: () => createMainWindow(),
createInvisibleWindow: () => createInvisibleWindow(),
registerGlobalShortcuts: () => registerGlobalShortcuts(), registerGlobalShortcuts: () => registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry),
getOverlayWindows: () => getOverlayWindows(), getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification, showDesktopNotification,
@@ -2975,9 +2955,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
initializeOverlayRuntimeBootstrapDeps: { initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore, initializeOverlayRuntimeCore,
setInvisibleOverlayVisible: (visible) => {
overlayManager.setInvisibleOverlayVisible(visible);
},
setOverlayRuntimeInitialized: (initialized) => { setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized; appState.overlayRuntimeInitialized = initialized;
}, },
@@ -3035,39 +3012,26 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow(); createMainWindow();
} }
const invisibleWindow = overlayManager.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) {
createInvisibleWindow();
}
} }
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
setVisibleOverlayVisibleHandler(visible); setVisibleOverlayVisibleHandler(visible);
} syncOverlayMpvSubtitleSuppression();
function setInvisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
setInvisibleOverlayVisibleHandler(visible);
if (visible) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
} }
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
toggleVisibleOverlayHandler(); toggleVisibleOverlayHandler();
} syncOverlayMpvSubtitleSuppression();
function toggleInvisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
toggleInvisibleOverlayHandler();
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
setOverlayVisibleHandler(visible); setOverlayVisibleHandler(visible);
syncOverlayMpvSubtitleSuppression();
} }
function toggleOverlay(): void { function toggleOverlay(): void {
toggleOverlayHandler(); toggleOverlayHandler();
syncOverlayMpvSubtitleSuppression();
} }
function handleOverlayModalClosed(modal: OverlayHostedModal): void { function handleOverlayModalClosed(modal: OverlayHostedModal): void {
handleOverlayModalClosedHandler(modal); handleOverlayModalClosedHandler(modal);
@@ -3077,11 +3041,6 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcHandler(command); handleMpvCommandFromIpcHandler(command);
} }
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
appState.hoveredSubtitleTokenIndex = tokenIndex;
applyHoveredTokenOverlay();
}
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> { async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>; return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
} }

View File

@@ -12,6 +12,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyTray: () => calls.push('destroy-tray'), destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'), stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -33,7 +34,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
}); });
cleanup(); cleanup();
assert.equal(calls.length, 21); assert.equal(calls.length, 22);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); 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 calls: string[] = [];
const restore = createRestoreWindowsOnActivateHandler({ const restore = createRestoreWindowsOnActivateHandler({
createMainWindow: () => calls.push('main'), createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible-sync'), updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'), syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
}); });
restore(); restore();
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']); assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']);
}); });

View File

@@ -2,6 +2,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyTray: () => void; destroyTray: () => void;
stopConfigHotReload: () => void; stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void; restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
@@ -25,6 +26,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyTray(); deps.destroyTray();
deps.stopConfigHotReload(); deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
deps.restoreMpvSubVisibilityForInvisibleOverlay();
deps.unregisterAllGlobalShortcuts(); deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket(); deps.stopSubtitleWebsocket();
deps.stopTexthookerService(); deps.stopTexthookerService();
@@ -55,14 +57,12 @@ export function createShouldRestoreWindowsOnActivateHandler(deps: {
export function createRestoreWindowsOnActivateHandler(deps: { export function createRestoreWindowsOnActivateHandler(deps: {
createMainWindow: () => void; createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void; syncOverlayMpvSubtitleSuppression: () => void;
}) { }) {
return (): void => { return (): void => {
deps.createMainWindow(); deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility(); deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility(); deps.syncOverlayMpvSubtitleSuppression();
}; };
} }

View File

@@ -14,6 +14,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
destroyTray: () => calls.push('destroy-tray'), destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'), stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -72,6 +73,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
destroyTray: () => {}, destroyTray: () => {},
stopConfigHotReload: () => {}, stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {}, restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibilityForInvisibleOverlay: () => {},
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},

View File

@@ -21,6 +21,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => void; destroyTray: () => void;
stopConfigHotReload: () => void; stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void; restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
@@ -51,6 +52,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
destroyTray: () => deps.destroyTray(), destroyTray: () => deps.destroyTray(),
stopConfigHotReload: () => deps.stopConfigHotReload(), stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(), restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibilityForInvisibleOverlay: () =>
deps.restoreMpvSubVisibilityForInvisibleOverlay(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(), stopTexthookerService: () => deps.stopTexthookerService(),

View File

@@ -16,6 +16,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
destroyTray: () => {}, destroyTray: () => {},
stopConfigHotReload: () => {}, stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {}, restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibilityForInvisibleOverlay: () => {},
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
@@ -43,9 +44,8 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
}, },
restoreWindowsOnActivateMainDeps: { restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {}, createMainWindow: () => {},
createInvisibleWindow: () => {},
updateVisibleOverlayVisibility: () => {}, updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {}, syncOverlayMpvSubtitleSuppression: () => {},
}, },
}); });

View File

@@ -33,6 +33,7 @@ export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
export function createHandleMpvMediaPathChangeHandler(deps: { export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
getCurrentAnilistMediaKey: () => string | null; getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void; resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void; maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -44,6 +45,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.updateCurrentMediaPath(path); deps.updateCurrentMediaPath(path);
if (!path) { if (!path) {
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibilityForInvisibleOverlay();
} }
const mediaKey = deps.getCurrentAnilistMediaKey(); const mediaKey = deps.getCurrentAnilistMediaKey();
deps.resetAnilistMediaTracking(mediaKey); deps.resetAnilistMediaTracking(mediaKey);

View File

@@ -8,6 +8,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
const bind = createBindMpvMainEventHandlersHandler({ const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => false, hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => 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}`), broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`), updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key', getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`), resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), 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('subtitle-change')?.({ text: 'line' });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' }); handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('time-pos-change')?.({ time: 2.5 }); handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('pause-change')?.({ paused: true }); 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('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change:line')); assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('media-title:Episode 1')); 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('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1')); assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('progress:normal')); assert.ok(calls.includes('progress:normal'));

View File

@@ -19,6 +19,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: { export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean; hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean; isQuitOnDisconnectArmed: () => boolean;
@@ -42,6 +43,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
broadcastSecondarySubtitle: (text: string) => void; broadcastSecondarySubtitle: (text: string) => void;
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
getCurrentAnilistMediaKey: () => string | null; getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void; resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void; maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -63,6 +65,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -94,6 +97,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({ const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path), updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
restoreMpvSubVisibilityForInvisibleOverlay: () =>
deps.restoreMpvSubVisibilityForInvisibleOverlay(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey), resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),

View File

@@ -32,6 +32,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
}, },
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
maybeRunAnilistPostWatchUpdate: async () => { maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch'); 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)}`), calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key', getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`), resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${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.scheduleQuitCheck(() => calls.push('scheduled-callback'));
deps.quitApp(); deps.quitApp();
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();
deps.syncOverlayMpvSubtitleSuppression();
deps.recordImmersionSubtitleLine('x', 0, 1); deps.recordImmersionSubtitleLine('x', 0, 1);
assert.equal(deps.hasSubtitleTimingTracker(), true); assert.equal(deps.hasSubtitleTimingTracker(), true);
deps.recordSubtitleTiming('y', 0, 1); 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.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec'); deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video'); deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibilityForInvisibleOverlay();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key'); assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key'); deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('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.playbackPaused, true);
assert.equal(appState.previousSecondarySubVisibility, true); assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped')); 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('anilist-post-watch'));
assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics')); assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
}); });

View File

@@ -21,11 +21,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => void; scheduleQuitCheck: (callback: () => void) => void;
quitApp: () => void; quitApp: () => void;
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>; maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void; logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibilityForInvisibleOverlay: () => void;
getCurrentAnilistMediaKey: () => string | null; getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void; resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void; maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -39,6 +41,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}) { }) {
return () => ({ return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay), hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(), isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
@@ -68,6 +71,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSecondarySubtitle: (text: string) => broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text), deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibilityForInvisibleOverlay: () =>
deps.restoreMpvSubVisibilityForInvisibleOverlay(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) => resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey), deps.resetAnilistMediaTracking(mediaKey),

View File

@@ -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, []);
});

View File

@@ -0,0 +1,147 @@
type MpvVisibilityClient = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
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<void> => {
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);
};
}

View File

@@ -1,11 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler, createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps'; } from './overlay-window-factory-main-deps';
test('overlay window factory main deps builders return mapped handlers', () => { 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({ const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
createOverlayWindowCore: (kind) => ({ kind }), createOverlayWindowCore: (kind) => ({ kind }),
isDev: true, isDev: true,
getOverlayDebugVisualizationEnabled: () => false,
ensureOverlayWindowLevel: () => calls.push('ensure-level'), ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
@@ -24,7 +21,6 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const overlayDeps = buildOverlayDeps(); const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true); assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false);
assert.equal(overlayDeps.isOverlayVisible('visible'), true); assert.equal(overlayDeps.isOverlayVisible('visible'), true);
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
@@ -34,20 +30,6 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const mainDeps = buildMainDeps(); const mainDeps = buildMainDeps();
mainDeps.setMainWindow(null); 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({ const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'modal' }), createOverlayWindow: () => ({ id: 'modal' }),
setModalWindow: () => calls.push('set-modal'), setModalWindow: () => calls.push('set-modal'),
@@ -55,5 +37,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const modalDeps = buildModalDeps(); const modalDeps = buildModalDeps();
modalDeps.setModalWindow(null); modalDeps.setModalWindow(null);
assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary', 'set-modal']); assert.deepEqual(calls, ['set-main', 'set-modal']);
}); });

View File

@@ -1,30 +1,27 @@
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: ( createOverlayWindowCore: (
kind: 'visible' | 'invisible' | 'secondary' | 'modal', kind: 'visible' | 'modal',
options: { options: {
isDev: boolean; isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void; ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean; isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void;
}, },
) => TWindow; ) => TWindow;
isDev: boolean; isDev: boolean;
getOverlayDebugVisualizationEnabled: () => boolean;
ensureOverlayWindowLevel: (window: TWindow) => void; ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean; isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void;
}) { }) {
return () => ({ return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore, createOverlayWindowCore: deps.createOverlayWindowCore,
isDev: deps.isDev, isDev: deps.isDev,
getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled,
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
@@ -35,7 +32,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
} }
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setMainWindow: (window: TWindow | null) => void; setMainWindow: (window: TWindow | null) => void;
}) { }) {
return () => ({ return () => ({
@@ -44,28 +41,8 @@ export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
}); });
} }
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setInvisibleWindow: deps.setInvisibleWindow,
});
}
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setSecondaryWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setSecondaryWindow: deps.setSecondaryWindow,
});
}
export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow; createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setModalWindow: (window: TWindow | null) => void; setModalWindow: (window: TWindow | null) => void;
}) { }) {
return () => ({ return () => ({

View File

@@ -1,11 +1,9 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler, createCreateMainWindowHandler,
createCreateModalWindowHandler, createCreateModalWindowHandler,
createCreateOverlayWindowHandler, createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler,
} from './overlay-window-factory'; } from './overlay-window-factory';
test('create overlay window handler forwards options and kind', () => { 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) => { createOverlayWindowCore: (kind, options) => {
calls.push(`kind:${kind}`); calls.push(`kind:${kind}`);
assert.equal(options.isDev, true); assert.equal(options.isDev, true);
assert.equal(options.overlayDebugVisualizationEnabled, false);
assert.equal(options.isOverlayVisible('visible'), true); assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('invisible'), false); assert.equal(options.isOverlayVisible('modal'), false);
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true); options.setOverlayDebugVisualizationEnabled(true);
options.onWindowClosed(kind); options.onWindowClosed(kind);
return window; return window;
}, },
isDev: true, isDev: true,
getOverlayDebugVisualizationEnabled: () => false,
ensureOverlayWindowLevel: () => {}, ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => calls.push('runtime-options'), onRuntimeOptionsChanged: () => calls.push('runtime-options'),
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), 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']); 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', () => { test('create modal window handler stores modal window', () => {
const calls: string[] = []; const calls: string[] = [];
const modalWindow = { id: 'modal' }; const modalWindow = { id: 'modal' };

View File

@@ -1,11 +1,10 @@
type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal'; type OverlayWindowKind = 'visible' | 'modal';
export function createCreateOverlayWindowHandler<TWindow>(deps: { export function createCreateOverlayWindowHandler<TWindow>(deps: {
createOverlayWindowCore: ( createOverlayWindowCore: (
kind: OverlayWindowKind, kind: OverlayWindowKind,
options: { options: {
isDev: boolean; isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void; ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
@@ -15,7 +14,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
}, },
) => TWindow; ) => TWindow;
isDev: boolean; isDev: boolean;
getOverlayDebugVisualizationEnabled: () => boolean;
ensureOverlayWindowLevel: (window: TWindow) => void; ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void; onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
@@ -26,7 +24,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
return (kind: OverlayWindowKind): TWindow => { return (kind: OverlayWindowKind): TWindow => {
return deps.createOverlayWindowCore(kind, { return deps.createOverlayWindowCore(kind, {
isDev: deps.isDev, isDev: deps.isDev,
overlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled(),
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
@@ -48,28 +45,6 @@ export function createCreateMainWindowHandler<TWindow>(deps: {
}; };
} }
export function createCreateInvisibleWindowHandler<TWindow>(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<TWindow>(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<TWindow>(deps: { export function createCreateModalWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow; createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setModalWindow: (window: TWindow | null) => void; setModalWindow: (window: TWindow | null) => void;

View File

@@ -2,10 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createOverlayWindowRuntimeHandlers } from './overlay-window-runtime-handlers'; 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 mainWindow: { kind: string } | null = null;
let invisibleWindow: { kind: string } | null = null;
let secondaryWindow: { kind: string } | null = null;
let modalWindow: { kind: string } | null = null; let modalWindow: { kind: string } | null = null;
let debugEnabled = false; let debugEnabled = false;
const calls: string[] = []; const calls: string[] = [];
@@ -14,7 +12,6 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }), createOverlayWindowCore: (kind) => ({ kind }),
isDev: true, isDev: true,
getOverlayDebugVisualizationEnabled: () => debugEnabled,
ensureOverlayWindowLevel: () => calls.push('ensure-level'), ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
setOverlayDebugVisualizationEnabled: (enabled) => { setOverlayDebugVisualizationEnabled: (enabled) => {
@@ -27,29 +24,17 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
setMainWindow: (window) => { setMainWindow: (window) => {
mainWindow = window; mainWindow = window;
}, },
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
setSecondaryWindow: (window) => {
secondaryWindow = window;
},
setModalWindow: (window) => { setModalWindow: (window) => {
modalWindow = window; modalWindow = window;
}, },
}); });
assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' }); assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' });
assert.deepEqual(runtime.createOverlayWindow('invisible'), { kind: 'invisible' }); assert.deepEqual(runtime.createOverlayWindow('modal'), { kind: 'modal' });
assert.deepEqual(runtime.createOverlayWindow('secondary'), { kind: 'secondary' });
assert.deepEqual(runtime.createMainWindow(), { kind: 'visible' }); assert.deepEqual(runtime.createMainWindow(), { kind: 'visible' });
assert.deepEqual(mainWindow, { 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(runtime.createModalWindow(), { kind: 'modal' });
assert.deepEqual(modalWindow, { kind: 'modal' }); assert.deepEqual(modalWindow, { kind: 'modal' });

View File

@@ -1,16 +1,12 @@
import { import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler, createCreateMainWindowHandler,
createCreateModalWindowHandler, createCreateModalWindowHandler,
createCreateOverlayWindowHandler, createCreateOverlayWindowHandler,
createCreateSecondaryWindowHandler,
} from './overlay-window-factory'; } from './overlay-window-factory';
import { import {
createBuildCreateInvisibleWindowMainDepsHandler,
createBuildCreateMainWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler,
createBuildCreateModalWindowMainDepsHandler, createBuildCreateModalWindowMainDepsHandler,
createBuildCreateOverlayWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler,
createBuildCreateSecondaryWindowMainDepsHandler,
} from './overlay-window-factory-main-deps'; } from './overlay-window-factory-main-deps';
type CreateOverlayWindowMainDeps<TWindow> = Parameters< type CreateOverlayWindowMainDeps<TWindow> = Parameters<
@@ -20,8 +16,6 @@ type CreateOverlayWindowMainDeps<TWindow> = Parameters<
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: { export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>; createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
setMainWindow: (window: TWindow | null) => void; setMainWindow: (window: TWindow | null) => void;
setInvisibleWindow: (window: TWindow | null) => void;
setSecondaryWindow: (window: TWindow | null) => void;
setModalWindow: (window: TWindow | null) => void; setModalWindow: (window: TWindow | null) => void;
}) { }) {
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>( const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
@@ -33,18 +27,6 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
setMainWindow: (window) => deps.setMainWindow(window), setMainWindow: (window) => deps.setMainWindow(window),
})(), })(),
); );
const createInvisibleWindow = createCreateInvisibleWindowHandler<TWindow>(
createBuildCreateInvisibleWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setInvisibleWindow: (window) => deps.setInvisibleWindow(window),
})(),
);
const createSecondaryWindow = createCreateSecondaryWindowHandler<TWindow>(
createBuildCreateSecondaryWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
})(),
);
const createModalWindow = createCreateModalWindowHandler<TWindow>( const createModalWindow = createCreateModalWindowHandler<TWindow>(
createBuildCreateModalWindowMainDepsHandler<TWindow>({ createBuildCreateModalWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind), createOverlayWindow: (kind) => createOverlayWindow(kind),
@@ -55,8 +37,6 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
return { return {
createOverlayWindow, createOverlayWindow,
createMainWindow, createMainWindow,
createInvisibleWindow,
createSecondaryWindow,
createModalWindow, createModalWindow,
}; };
} }

View File

@@ -156,8 +156,6 @@ export interface AppState {
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
currentSubtitleData: SubtitleData | null; currentSubtitleData: SubtitleData | null;
hoveredSubtitleTokenIndex: number | null;
hoveredSubtitleRevision: number;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null; subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null; currentMediaPath: string | null;
@@ -173,6 +171,9 @@ export interface AppState {
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
lastSecondarySubToggleAtMs: number; lastSecondarySubToggleAtMs: number;
previousSecondarySubVisibility: boolean | null; previousSecondarySubVisibility: boolean | null;
overlaySavedMpvSubVisibility: boolean | null;
overlaySavedSecondaryMpvSubVisibility: boolean | null;
overlayMpvSubVisibilityRevision: number;
mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics; mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics;
shortcutsRegistered: boolean; shortcutsRegistered: boolean;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
@@ -230,8 +231,6 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentSubText: '', currentSubText: '',
currentSubAssText: '', currentSubAssText: '',
currentSubtitleData: null, currentSubtitleData: null,
hoveredSubtitleTokenIndex: null,
hoveredSubtitleRevision: 0,
windowTracker: null, windowTracker: null,
subtitlePosition: null, subtitlePosition: null,
currentMediaPath: null, currentMediaPath: null,
@@ -247,6 +246,9 @@ export function createAppState(values: AppStateInitialValues): AppState {
secondarySubMode: 'hover', secondarySubMode: 'hover',
lastSecondarySubToggleAtMs: 0, lastSecondarySubToggleAtMs: 0,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
overlaySavedMpvSubVisibility: null,
overlaySavedSecondaryMpvSubVisibility: null,
overlayMpvSubVisibilityRevision: 0,
mpvSubtitleRenderMetrics: { mpvSubtitleRenderMetrics: {
...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
}, },

View File

@@ -45,7 +45,6 @@ import type {
RuntimeOptionId, RuntimeOptionId,
RuntimeOptionState, RuntimeOptionState,
RuntimeOptionValue, RuntimeOptionValue,
MpvSubtitleRenderMetrics,
OverlayContentMeasurement, OverlayContentMeasurement,
ShortcutsConfig, ShortcutsConfig,
ConfigHotReloadPayload, ConfigHotReloadPayload,
@@ -55,12 +54,80 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
const overlayLayer = const overlayLayer =
overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'modal' ? overlayLayerFromArg : null;
overlayLayerFromArg === 'invisible' ||
overlayLayerFromArg === 'secondary' || type EmptyListener = () => void;
overlayLayerFromArg === 'modal' type PayloadedListener<T> = (payload: T) => void;
? overlayLayerFromArg
: null; 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<T>(
channel: string,
normalize: (payload: unknown) => T,
): (listener: PayloadedListener<T>) => void {
const pending: T[] = [];
const listeners: PayloadedListener<T>[] = [];
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<T>): 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<SubsyncManualPayload>(
IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload,
);
const onKikuFieldGroupingRequestEvent = createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer, getOverlayLayer: () => overlayLayer,
@@ -94,16 +161,6 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> => getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), 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) => { onSubtitleAss: (callback: (assText: string) => void) => {
ipcRenderer.on( ipcRenderer.on(
IPC_CHANNELS.event.subtitleAssSet, 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 }) => { setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options); ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options);
@@ -201,23 +250,11 @@ const electronAPI: ElectronAPI = {
focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise<void>, focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise<void>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> => getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle), ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => { onSubsyncManualOpen: onSubsyncManualOpenEvent,
ipcRenderer.on(
IPC_CHANNELS.event.subsyncOpenManual,
(_event: IpcRendererEvent, payload: SubsyncManualPayload) => {
callback(payload);
},
);
},
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> => runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => { onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
ipcRenderer.on(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data),
);
},
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> => kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request), ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
@@ -242,27 +279,19 @@ const electronAPI: ElectronAPI = {
}, },
); );
}, },
onOpenRuntimeOptions: (callback: () => void) => { onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
ipcRenderer.on(IPC_CHANNELS.event.runtimeOptionsOpen, () => { onOpenJimaku: onOpenJimakuEvent,
callback();
});
},
onOpenJimaku: (callback: () => void) => {
ipcRenderer.on(IPC_CHANNELS.event.jimakuOpen, () => {
callback();
});
},
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> => appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
}, },
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
},
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement); ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
}, },
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
ipcRenderer.send('subtitle-token-hover:set', tokenIndex);
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on( ipcRenderer.on(
IPC_CHANNELS.event.configHotReload, IPC_CHANNELS.event.configHotReload,

View File

@@ -2,6 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createRendererRecoveryController } from './error-recovery.js'; import { createRendererRecoveryController } from './error-recovery.js';
import {
YOMITAN_POPUP_IFRAME_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
} from './yomitan-popup.js';
import { resolvePlatformInfo } from './utils/platform.js'; import { resolvePlatformInfo } from './utils/platform.js';
test('handleError logs context and recovers overlay state', () => { test('handleError logs context and recovers overlay state', () => {
@@ -26,7 +31,6 @@ test('handleError logs context and recovers overlay state', () => {
secondarySubtitlePreview: 'secondary', secondarySubtitlePreview: 'secondary',
isOverlayInteractive: true, isOverlayInteractive: true,
isOverSubtitle: true, isOverSubtitle: true,
invisiblePositionEditMode: false,
overlayLayer: 'visible', overlayLayer: 'visible',
}), }),
logError: (payload) => { logError: (payload) => {
@@ -72,8 +76,7 @@ test('handleError normalizes non-Error values', () => {
secondarySubtitlePreview: '', secondarySubtitlePreview: '',
isOverlayInteractive: false, isOverlayInteractive: false,
isOverSubtitle: false, isOverSubtitle: false,
invisiblePositionEditMode: false, overlayLayer: 'visible',
overlayLayer: 'invisible',
}), }),
logError: (payload) => { logError: (payload) => {
payloads.push(payload); payloads.push(payload);
@@ -107,7 +110,6 @@ test('nested recovery errors are ignored while current recovery is active', () =
secondarySubtitlePreview: '', secondarySubtitlePreview: '',
isOverlayInteractive: true, isOverlayInteractive: true,
isOverSubtitle: false, isOverSubtitle: false,
invisiblePositionEditMode: true,
overlayLayer: 'visible', overlayLayer: 'visible',
}), }),
logError: (payload) => { logError: (payload) => {
@@ -130,7 +132,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
configurable: true, configurable: true,
value: { value: {
electronAPI: { electronAPI: {
getOverlayLayer: () => 'invisible', getOverlayLayer: () => 'modal',
}, },
location: { search: '?layer=visible' }, location: { search: '?layer=visible' },
}, },
@@ -146,7 +148,6 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
try { try {
const info = resolvePlatformInfo(); const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'visible'); assert.equal(info.overlayLayer, 'visible');
assert.equal(info.isInvisibleLayer, false);
} finally { } finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', { 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 previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator; const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
@@ -179,9 +180,8 @@ test('resolvePlatformInfo supports secondary layer and disables mouse-ignore tog
try { try {
const info = resolvePlatformInfo(); const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'secondary'); assert.equal(info.overlayLayer, 'visible');
assert.equal(info.isSecondaryLayer, true); assert.equal(info.shouldToggleMouseIgnore, true);
assert.equal(info.shouldToggleMouseIgnore, false);
} finally { } finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', { 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);
});

View File

@@ -16,8 +16,7 @@ export type RendererRecoverySnapshot = {
secondarySubtitlePreview: string; secondarySubtitlePreview: string;
isOverlayInteractive: boolean; isOverlayInteractive: boolean;
isOverSubtitle: boolean; isOverSubtitle: boolean;
invisiblePositionEditMode: boolean; overlayLayer: 'visible' | 'modal';
overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal';
}; };
type NormalizedRendererError = { type NormalizedRendererError = {

View File

@@ -18,7 +18,6 @@
import type { import type {
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
MpvSubtitleRenderMetrics,
RuntimeOptionState, RuntimeOptionState,
SecondarySubMode, SecondarySubMode,
SubtitleData, SubtitleData,
@@ -84,10 +83,7 @@ function syncSettingsModalSubtitleSuppression(): void {
const subtitleRenderer = createSubtitleRenderer(ctx); const subtitleRenderer = createSubtitleRenderer(ctx);
const measurementReporter = createOverlayContentMeasurementReporter(ctx); const measurementReporter = createOverlayContentMeasurementReporter(ctx);
const positioning = createPositioningController(ctx, { const positioning = createPositioningController(ctx);
modalStateReader: { isAnySettingsModalOpen },
applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize,
});
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, { const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
@@ -115,25 +111,15 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal, openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
appendClipboardVideoToQueue: () => { appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue(); void window.electronAPI.appendClipboardVideoToQueue();
}, },
}); });
const mouseHandlers = createMouseHandlers(ctx, { const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyInvisibleSubtitleLayoutFromMpvMetrics:
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics,
applyYPercent: positioning.applyYPercent, applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent, getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
reportHoveredTokenIndex: (tokenIndex: number | null) => {
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
},
}); });
let lastSubtitlePreview = ''; let lastSubtitlePreview = '';
@@ -179,9 +165,6 @@ function dismissActiveUiAfterError(): void {
function restoreOverlayInteractionAfterError(): void { function restoreOverlayInteractionAfterError(): void {
ctx.state.isOverSubtitle = false; ctx.state.isOverSubtitle = false;
if (ctx.state.invisiblePositionEditMode) {
positioning.setInvisiblePositionEditMode(false);
}
ctx.dom.overlay.classList.remove('interactive'); ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
@@ -212,7 +195,6 @@ const recovery = createRendererRecoveryController({
secondarySubtitlePreview: lastSecondarySubtitlePreview, secondarySubtitlePreview: lastSecondarySubtitlePreview,
isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'), isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'),
isOverSubtitle: ctx.state.isOverSubtitle, isOverSubtitle: ctx.state.isOverSubtitle,
invisiblePositionEditMode: ctx.state.invisiblePositionEditMode,
overlayLayer: ctx.platform.overlayLayer, overlayLayer: ctx.platform.overlayLayer,
}), }),
logError: (payload) => { logError: (payload) => {
@@ -222,6 +204,41 @@ const recovery = createRendererRecoveryController({
registerRendererGlobalErrorHandlers(window, recovery); 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 { function runGuarded(action: string, fn: () => void): void {
try { try {
fn(); fn();
@@ -238,6 +255,8 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
}); });
} }
registerModalOpenHandlers();
async function init(): Promise<void> { async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
if (ctx.platform.isMacOSPlatform) { if (ctx.platform.isMacOSPlatform) {
@@ -252,41 +271,17 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(data.text); lastSubtitlePreview = truncateForErrorLog(data.text);
} }
subtitleRenderer.renderSubtitle(data); subtitleRenderer.renderSubtitle(data);
if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics,
'subtitle-change',
);
}
measurementReporter.schedule(); measurementReporter.schedule();
}); });
}); });
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => { runGuarded('subtitle-position:update', () => {
if (ctx.platform.isInvisibleLayer) { positioning.applyStoredSubtitlePosition(position, 'media-change');
positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change');
} else {
positioning.applyStoredSubtitlePosition(position, 'media-change');
}
measurementReporter.schedule(); 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(); const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
lastSubtitlePreview = truncateForErrorLog(initialSubtitle); lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
subtitleRenderer.renderSubtitle(initialSubtitle); subtitleRenderer.renderSubtitle(initialSubtitle);
@@ -310,17 +305,11 @@ async function init(): Promise<void> {
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule(); measurementReporter.schedule();
const hoverTarget = ctx.platform.isInvisibleLayer ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
? ctx.dom.subtitleRoot ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
: ctx.dom.subtitleContainer;
hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter); ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
mouseHandlers.setupInvisibleHoverSelection();
mouseHandlers.setupInvisibleTokenHoverReporter();
positioning.setupInvisiblePositionEditHud();
mouseHandlers.setupResizeHandler(); mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver(); mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver(); mouseHandlers.setupYomitanObserver();
@@ -348,59 +337,14 @@ async function init(): Promise<void> {
measurementReporter.schedule(); measurementReporter.schedule();
}); });
}); });
window.electronAPI.onOpenRuntimeOptions(() => { mouseHandlers.setupDragging();
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();
}
await keyboardHandlers.setupMpvInputForwarding(); await keyboardHandlers.setupMpvInputForwarding();
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
if (ctx.platform.isInvisibleLayer) { positioning.applyStoredSubtitlePosition(await window.electronAPI.getSubtitlePosition(), 'startup');
positioning.applyInvisibleStoredSubtitlePosition( measurementReporter.schedule();
await window.electronAPI.getSubtitlePosition(),
'startup',
);
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
await window.electronAPI.getMpvSubtitleRenderMetrics(),
'startup',
);
} else {
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
}
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
@@ -421,7 +365,7 @@ function setupDragDropToMpvQueue(): void {
const clearDropInteractive = (): void => { const clearDropInteractive = (): void => {
dragDepth = 0; dragDepth = 0;
if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) { if (isAnyModalOpen() || ctx.state.isOverSubtitle) {
return; return;
} }
ctx.dom.overlay.classList.remove('interactive'); ctx.dom.overlay.classList.remove('interactive');

View File

@@ -280,6 +280,8 @@ body {
text-align: center; text-align: center;
font-size: 35px; font-size: 35px;
line-height: var(--visible-sub-line-height, 1.32); line-height: var(--visible-sub-line-height, 1.32);
overflow-wrap: anywhere;
word-break: keep-all;
color: #cad3f5; color: #cad3f5;
--subtitle-known-word-color: #a6da95; --subtitle-known-word-color: #a6da95;
--subtitle-n-plus-one-color: #c6a0f6; --subtitle-n-plus-one-color: #c6a0f6;
@@ -288,6 +290,8 @@ body {
--subtitle-jlpt-n3-color: #f9e2af; --subtitle-jlpt-n3-color: #f9e2af;
--subtitle-jlpt-n4-color: #a6e3a1; --subtitle-jlpt-n4-color: #a6e3a1;
--subtitle-jlpt-n5-color: #8aadf4; --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-single-color: #f5a97f;
--subtitle-frequency-band-1-color: #ed8796; --subtitle-frequency-band-1-color: #ed8796;
--subtitle-frequency-band-2-color: #f5a97f; --subtitle-frequency-band-2-color: #f5a97f;
@@ -300,6 +304,7 @@ body {
/* Enable text selection for Yomitan */ /* Enable text selection for Yomitan */
user-select: text; user-select: text;
cursor: text; cursor: text;
-webkit-text-fill-color: currentColor;
} }
#subtitleRoot:empty { #subtitleRoot:empty {
@@ -318,16 +323,21 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot .c { #subtitleRoot .c {
display: inline; display: inline;
position: relative; position: relative;
color: inherit;
-webkit-text-fill-color: currentColor !important;
} }
#subtitleRoot .c:hover { #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; border-radius: 2px;
} }
#subtitleRoot .word { #subtitleRoot .word {
display: inline; display: inline;
position: relative; position: relative;
-webkit-text-fill-color: currentColor !important;
} }
#subtitleRoot .word.word-known { #subtitleRoot .word.word-known {
@@ -418,9 +428,103 @@ body.settings-modal-open #subtitleContainer {
color: var(--subtitle-frequency-band-5-color, #8aadf4); color: var(--subtitle-frequency-band-5-color, #8aadf4);
} }
#subtitleRoot .word:hover { #subtitleRoot .word:not(.word-known):not(.word-n-plus-one):not(.word-frequency-single):not(
background: rgba(255, 255, 255, 0.2); .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; 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 { #subtitleRoot br {
@@ -439,93 +543,6 @@ body.platform-macos.layer-visible #subtitleRoot {
background: transparent; 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 { #secondarySubContainer {
position: absolute; position: absolute;
top: 40px; top: 40px;
@@ -538,40 +555,6 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c {
pointer-events: auto; 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 #subtitleContainer,
body.layer-modal #secondarySubContainer { body.layer-modal #secondarySubContainer {
display: none !important; display: none !important;
@@ -597,10 +580,6 @@ body.layer-modal #overlay {
cursor: text; cursor: text;
} }
body.layer-secondary #secondarySubRoot {
max-width: 100%;
}
#secondarySubRoot:empty { #secondarySubRoot:empty {
display: none; display: none;
} }
@@ -644,11 +623,7 @@ body.settings-modal-open #secondarySubContainer {
opacity: 1; opacity: 1;
} }
body.layer-secondary #secondarySubContainer.secondary-sub-hover { iframe.yomitan-popup,
padding: 8px 12px;
align-items: center;
}
iframe[id^='yomitan-popup'] { iframe[id^='yomitan-popup'] {
pointer-events: auto !important; pointer-events: auto !important;
z-index: 2147483647 !important; z-index: 2147483647 !important;

View File

@@ -10,9 +10,9 @@ import {
buildInvisibleTokenHoverRanges, buildInvisibleTokenHoverRanges,
computeWordClass, computeWordClass,
normalizeSubtitle, normalizeSubtitle,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle, shouldRenderTokenizedSubtitle,
} from './subtitle-render.js'; } from './subtitle-render.js';
import { resolveInvisibleLineHeight } from './positioning/invisible-layout-helpers.js';
function createToken(overrides: Partial<MergedToken>): MergedToken { function createToken(overrides: Partial<MergedToken>): MergedToken {
return { return {
@@ -210,6 +210,17 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
assert.equal(actual, 'word'); 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', () => { test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
const tokens = [ const tokens = [
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }), 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', () => { test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
assert.equal(shouldRenderTokenizedSubtitle(true, 5), false); assert.equal(shouldRenderTokenizedSubtitle(5), true);
}); assert.equal(shouldRenderTokenizedSubtitle(0), 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('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css'); const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', '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)) { if (!fs.existsSync(cssPath)) {
assert.fail( assert.fail(
'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.', '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\(/); 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( const visibleMacBlock = extractClassBlock(
cssText, cssText,
'body.platform-macos.layer-visible #subtitleRoot', 'body.platform-macos.layer-visible #subtitleRoot',
); );
assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/); assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/);
assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/); assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/);
});
test('invisible overlay uses looser line height on macOS for multi-line subtitles', () => { const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot');
assert.equal(resolveInvisibleLineHeight(1, true), '1.08'); assert.match(
assert.equal(resolveInvisibleLineHeight(2, true), '1.5'); subtitleRootBlock,
assert.equal(resolveInvisibleLineHeight(3, true), '1.62'); /--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', () => { const charBlock = extractClassBlock(cssText, '#subtitleRoot .c');
assert.equal(resolveInvisibleLineHeight(1, false), 'normal'); assert.match(charBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
assert.equal(resolveInvisibleLineHeight(2, false), 'normal');
assert.equal(resolveInvisibleLineHeight(4, false), 'normal'); 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,
);
}); });

View File

@@ -15,11 +15,8 @@ export type InvisibleTokenHoverRange = {
tokenIndex: number; tokenIndex: number;
}; };
export function shouldRenderTokenizedSubtitle( export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean {
isInvisibleLayer: boolean, return tokenCount > 0;
tokenCount: number,
): boolean {
return !isInvisibleLayer && tokenCount > 0;
} }
function isWhitespaceOnly(value: string): boolean { function isWhitespaceOnly(value: string): boolean {
@@ -47,6 +44,23 @@ function sanitizeHexColor(value: unknown, fallback: string): string {
: fallback; : 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 = { const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
enabled: false, enabled: false,
topX: 1000, topX: 1000,
@@ -79,6 +93,54 @@ function sanitizeFrequencyBandedColors(
]; ];
} }
function applyInlineStyleDeclarations(
target: HTMLElement,
declarations: Record<string, unknown>,
excludedKeys: ReadonlySet<string> = new Set<string>(),
): 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<string, string>;
styleTarget[key] = cssValue;
}
}
function pickInlineStyleDeclarations(
declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>,
): Record<string, unknown> {
const picked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(declarations)) {
if (!includedKeys.has(key)) continue;
picked[key] = value;
}
return picked;
}
const CONTAINER_STYLE_KEYS = new Set<string>([
'background',
'backgroundColor',
'backdropFilter',
'WebkitBackdropFilter',
'webkitBackdropFilter',
'-webkit-backdrop-filter',
]);
function getFrequencyDictionaryClass( function getFrequencyDictionaryClass(
token: MergedToken, token: MergedToken,
settings: FrequencyRenderSettings, settings: FrequencyRenderSettings,
@@ -337,11 +399,8 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
} }
export function createSubtitleRenderer(ctx: RendererContext) { export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void { function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.innerHTML = ''; ctx.dom.subtitleRoot.innerHTML = '';
ctx.state.lastHoverSelectionKey = '';
ctx.state.lastHoverSelectionNode = null;
ctx.state.lastHoveredTokenIndex = null;
let text: string; let text: string;
let tokens: MergedToken[] | null; let tokens: MergedToken[] | null;
@@ -358,22 +417,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (!text) return; 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); const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (shouldRenderTokenizedSubtitle(ctx.platform.isInvisibleLayer, tokens?.length ?? 0) && tokens) { if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
renderWithTokens( renderWithTokens(
ctx.dom.subtitleRoot, ctx.dom.subtitleRoot,
tokens, tokens,
@@ -444,17 +489,30 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleStyleConfig | null): void { function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
if (!style) return; if (!style) return;
const styleDeclarations = style as Record<string, unknown>;
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.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`; 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.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; 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 knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6'; const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
style.hoverTokenBackgroundColor,
);
const jlptColors = { const jlptColors = {
N1: ctx.state.jlptN1Color ?? '#ed8796', N1: ctx.state.jlptN1Color ?? '#ed8796',
N2: ctx.state.jlptN2Color ?? '#f5a97f', N2: ctx.state.jlptN2Color ?? '#f5a97f',
@@ -476,6 +534,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.nPlusOneColor = nPlusOneColor; ctx.state.nPlusOneColor = nPlusOneColor;
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor); 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-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.jlptN1Color = jlptColors.N1;
ctx.state.jlptN2Color = jlptColors.N2; ctx.state.jlptN2Color = jlptColors.N2;
ctx.state.jlptN3Color = jlptColors.N3; ctx.state.jlptN3Color = jlptColors.N3;
@@ -551,6 +614,17 @@ export function createSubtitleRenderer(ctx: RendererContext) {
const secondaryStyle = style.secondary; const secondaryStyle = style.secondary;
if (!secondaryStyle) return; if (!secondaryStyle) return;
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
applyInlineStyleDeclarations(
ctx.dom.secondarySubRoot,
secondaryStyleDeclarations,
CONTAINER_STYLE_KEYS,
);
applyInlineStyleDeclarations(
ctx.dom.secondarySubContainer,
pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
);
if (secondaryStyle.fontFamily) { if (secondaryStyle.fontFamily) {
ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily; ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily;
} }
@@ -566,9 +640,6 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (secondaryStyle.fontStyle) { if (secondaryStyle.fontStyle) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle; ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
} }
if (secondaryStyle.backgroundColor) {
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
}
} }
return { return {

View File

@@ -1,40 +1,25 @@
export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal'; export type OverlayLayer = 'visible' | 'modal';
export type PlatformInfo = { export type PlatformInfo = {
overlayLayer: OverlayLayer; overlayLayer: OverlayLayer;
isInvisibleLayer: boolean;
isSecondaryLayer: boolean;
isModalLayer: boolean; isModalLayer: boolean;
isLinuxPlatform: boolean; isLinuxPlatform: boolean;
isMacOSPlatform: boolean; isMacOSPlatform: boolean;
shouldToggleMouseIgnore: boolean; shouldToggleMouseIgnore: boolean;
invisiblePositionEditToggleCode: string;
invisiblePositionStepPx: number;
invisiblePositionStepFastPx: number;
}; };
export function resolvePlatformInfo(): PlatformInfo { export function resolvePlatformInfo(): PlatformInfo {
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
const queryLayer = new URLSearchParams(window.location.search).get('layer'); const queryLayer = new URLSearchParams(window.location.search).get('layer');
const overlayLayerFromQuery: OverlayLayer | null = const overlayLayerFromQuery: OverlayLayer | null =
queryLayer === 'visible' || queryLayer === 'visible' || queryLayer === 'modal' ? queryLayer : null;
queryLayer === 'invisible' ||
queryLayer === 'secondary' ||
queryLayer === 'modal'
? queryLayer
: null;
const overlayLayer: OverlayLayer = const overlayLayer: OverlayLayer =
overlayLayerFromQuery ?? overlayLayerFromQuery ??
(overlayLayerFromPreload === 'visible' || (overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'modal'
overlayLayerFromPreload === 'invisible' ||
overlayLayerFromPreload === 'secondary' ||
overlayLayerFromPreload === 'modal'
? overlayLayerFromPreload ? overlayLayerFromPreload
: 'visible'); : 'visible');
const isInvisibleLayer = overlayLayer === 'invisible';
const isSecondaryLayer = overlayLayer === 'secondary';
const isModalLayer = overlayLayer === 'modal'; const isModalLayer = overlayLayer === 'modal';
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform = const isMacOSPlatform =
@@ -42,14 +27,9 @@ export function resolvePlatformInfo(): PlatformInfo {
return { return {
overlayLayer, overlayLayer,
isInvisibleLayer,
isSecondaryLayer,
isModalLayer, isModalLayer,
isLinuxPlatform, isLinuxPlatform,
isMacOSPlatform, isMacOSPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer, shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
invisiblePositionEditToggleCode: 'KeyP',
invisiblePositionStepPx: 1,
invisiblePositionStepFastPx: 4,
}; };
} }

View File

@@ -19,15 +19,14 @@ export const IPC_CHANNELS = {
refreshKnownWords: 'anki:refresh-known-words', refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond', kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
reportOverlayContentBounds: 'overlay-content-bounds:report', reportOverlayContentBounds: 'overlay-content-bounds:report',
overlayModalOpened: 'overlay:modal-opened',
}, },
request: { request: {
getOverlayVisibility: 'get-overlay-visibility', getOverlayVisibility: 'get-overlay-visibility',
getVisibleOverlayVisibility: 'get-visible-overlay-visibility', getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
getInvisibleOverlayVisibility: 'get-invisible-overlay-visibility',
getCurrentSubtitle: 'get-current-subtitle', getCurrentSubtitle: 'get-current-subtitle',
getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass', getCurrentSubtitleAss: 'get-current-subtitle-ass',
getMpvSubtitleRenderMetrics: 'get-mpv-subtitle-render-metrics',
getSubtitlePosition: 'get-subtitle-position', getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style', getSubtitleStyle: 'get-subtitle-style',
getMecabStatus: 'get-mecab-status', getMecabStatus: 'get-mecab-status',
@@ -57,9 +56,7 @@ export const IPC_CHANNELS = {
subtitleSet: 'subtitle:set', subtitleSet: 'subtitle:set',
subtitleVisibility: 'mpv:subVisibility', subtitleVisibility: 'mpv:subVisibility',
subtitlePositionSet: 'subtitle-position:set', subtitlePositionSet: 'subtitle-position:set',
mpvSubtitleRenderMetricsSet: 'mpv-subtitle-render-metrics:set',
subtitleAssSet: 'subtitle-ass:set', subtitleAssSet: 'subtitle-ass:set',
overlayDebugVisualizationSet: 'overlay-debug-visualization:set',
secondarySubtitleSet: 'secondary-subtitle:set', secondarySubtitleSet: 'secondary-subtitle:set',
secondarySubtitleMode: 'secondary-subtitle:mode', secondarySubtitleMode: 'secondary-subtitle:mode',
subsyncOpenManual: 'subsync:open-manual', subsyncOpenManual: 'subsync:open-manual',

View File

@@ -71,8 +71,6 @@ export interface WindowGeometry {
export interface SubtitlePosition { export interface SubtitlePosition {
yPercent: number; yPercent: number;
invisibleOffsetXPx?: number;
invisibleOffsetYPx?: number;
} }
export interface SubtitleStyle { export interface SubtitleStyle {
@@ -272,6 +270,7 @@ export interface SubtitleStyleConfig {
enableJlpt?: boolean; enableJlpt?: boolean;
preserveLineBreaks?: boolean; preserveLineBreaks?: boolean;
hoverTokenColor?: string; hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;
fontFamily?: string; fontFamily?: string;
fontSize?: number; fontSize?: number;
fontColor?: string; fontColor?: string;
@@ -309,7 +308,6 @@ export type FrequencyDictionaryMode = 'single' | 'banded';
export interface ShortcutsConfig { export interface ShortcutsConfig {
toggleVisibleOverlayGlobal?: string | null; toggleVisibleOverlayGlobal?: string | null;
toggleInvisibleOverlayGlobal?: string | null;
copySubtitle?: string | null; copySubtitle?: string | null;
copySubtitleMultiple?: string | null; copySubtitleMultiple?: string | null;
updateLastCardFromClipboard?: string | null; updateLastCardFromClipboard?: string | null;
@@ -364,10 +362,6 @@ export interface DiscordPresenceConfig {
debounceMs?: number; debounceMs?: number;
} }
export interface InvisibleOverlayConfig {
startupVisibility?: 'platform-default' | 'visible' | 'hidden';
}
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off'; export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
export interface YoutubeSubgenConfig { export interface YoutubeSubgenConfig {
@@ -410,7 +404,6 @@ export interface Config {
anilist?: AnilistConfig; anilist?: AnilistConfig;
jellyfin?: JellyfinConfig; jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig; discordPresence?: DiscordPresenceConfig;
invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig; youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig; immersionTracking?: ImmersionTrackingConfig;
logging?: { logging?: {
@@ -540,7 +533,6 @@ export interface ResolvedConfig {
updateIntervalMs: number; updateIntervalMs: number;
debounceMs: number; debounceMs: number;
}; };
invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & { youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode; mode: YoutubeSubgenMode;
whisperBin: string; whisperBin: string;
@@ -630,7 +622,7 @@ export interface MpvSubtitleRenderMetrics {
} | null; } | null;
} }
export type OverlayLayer = 'visible' | 'invisible'; export type OverlayLayer = 'visible';
export interface OverlayContentRect { export interface OverlayContentRect {
x: number; x: number;
@@ -728,7 +720,7 @@ export interface SubtitleHoverTokenPayload {
} }
export interface ElectronAPI { export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | 'modal' | null; getOverlayLayer: () => 'visible' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void; onSubtitle: (callback: (data: SubtitleData) => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void; onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
@@ -736,10 +728,7 @@ export interface ElectronAPI {
getCurrentSubtitle: () => Promise<SubtitleData>; getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>; getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>; getCurrentSubtitleAss: () => Promise<string>;
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;
onSubtitleAss: (callback: (assText: string) => void) => void; onSubtitleAss: (callback: (assText: string) => void) => void;
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>; getSubtitlePosition: () => Promise<SubtitlePosition | null>;
@@ -781,8 +770,8 @@ export interface ElectronAPI {
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
} }