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