feat: bind overlay state to secondary subtitle mpv visibility

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

View File

@@ -13,11 +13,11 @@
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
// ==========================================
// 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.

View File

@@ -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<br/>transport · protocol<br/>properties · metrics"]:::svc
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc
Overlay["Overlay Manager<br/>window · visibility · bridge"]:::svc
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
@@ -172,9 +171,7 @@ flowchart LR
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
subgraph Rend["Renderer — src/renderer/"]
Visible["Visible window<br/>Yomitan lookups"]:::rend
Invisible["Invisible window<br/>mpv positioning"]:::rend
Secondary["Secondary window<br/>subtitle bar"]:::rend
Overlay["Main overlay window<br/>primary + secondary subtitles"]:::rend
UI["subtitle-render<br/>positioning<br/>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<br/>Runtime()"]:::phase
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
VisWin --> Warmups
InvWin --> Warmups
SecWin --> Warmups
MainWin --> Warmups
Shortcuts --> Warmups
Warmups["Background<br/>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<br/>normalize → tokenize → merge"]:::runtime
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime
Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
end
WarmupGroup --> Loop

View File

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

View File

@@ -24,11 +24,11 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization:
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
## 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.

View File

@@ -0,0 +1,55 @@
# Secondary Subtitles Main Overlay Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Ensure secondary subtitles render in the unified main overlay window and remove stale secondary-window/layer paths.
**Architecture:** Keep secondary subtitle DOM in the shared renderer tree, rely on mode classes (`secondary-sub-hidden|visible|hover`) for visibility, and remove obsolete legacy overlay-layer assumptions. Preserve modal behavior and existing subtitle rendering flow.
**Tech Stack:** TypeScript, Electron renderer CSS/DOM, Bun test runner.
---
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
**Files:**
- Modify: `src/renderer/subtitle-render.test.ts`
- Modify: `src/renderer/error-recovery.test.ts`
**Step 1: Write failing tests**
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
**Step 2: Run tests to verify failures**
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling.
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
**Files:**
- Modify: `src/renderer/style.css`
- Modify: `src/renderer/utils/platform.ts`
- Modify: `src/renderer/error-recovery.ts`
- Modify: `src/types.ts`
**Step 1: Implement minimal changes**
- Remove legacy forced hide on `#secondarySubContainer`.
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
- Narrow related overlay layer type unions.
**Step 2: Run targeted tests**
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
Expected: PASS.
### Task 3: Validate Wider Related Surface
**Files:**
- No additional code changes required.
**Step 1: Run broader related tests**
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/overlay-window-factory.test.ts src/core/services/overlay-manager.test.ts`
Expected: Renderer tests pass; report any unrelated pre-existing failures.

View File

@@ -21,8 +21,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test: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\"",

View File

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

View File

@@ -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';

View File

@@ -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<MpvProtocolCommand> = [

View File

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
import type { WindowGeometry } from '../../types';
export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2;
function toInteger(value: number): number {
return Number.isFinite(value) ? Math.round(value) : 0;
}
function clampPositive(value: number): number {
return Math.max(1, toInteger(value));
}
export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): {
secondary: WindowGeometry;
primary: WindowGeometry;
} {
const x = toInteger(geometry.x);
const y = toInteger(geometry.y);
const width = clampPositive(geometry.width);
const totalHeight = clampPositive(geometry.height);
const secondaryHeight = clampPositive(
Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)),
);
const primaryHeight = clampPositive(totalHeight - secondaryHeight);
return {
secondary: {
x,
y,
width,
height: secondaryHeight,
},
primary: {
x,
y: y + secondaryHeight,
width,
height: primaryHeight,
},
};
}

View File

@@ -1,37 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry';
test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => {
const regions = splitOverlayGeometryForSecondaryBar({
x: 100,
y: 50,
width: 1200,
height: 900,
});
assert.deepEqual(regions.secondary, {
x: 100,
y: 50,
width: 1200,
height: 180,
});
assert.deepEqual(regions.primary, {
x: 100,
y: 230,
width: 1200,
height: 720,
});
});
test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => {
const regions = splitOverlayGeometryForSecondaryBar({
x: 0,
y: 0,
width: 300,
height: 1,
});
assert.ok(regions.secondary.height >= 1);
assert.ok(regions.primary.height >= 1);
});

View File

@@ -4,8 +4,25 @@ import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
function getOverlayWindowHtmlPath(): string {
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
}
function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind): void {
overlayWindowLayerByInstance.set(window, layer);
const htmlPath = getOverlayWindowHtmlPath();
window
.loadFile(htmlPath, {
query: { layer },
})
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
}
export type OverlayWindowKind = 'visible' | 'modal';
export function updateOverlayWindowBounds(
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);
}

View File

@@ -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<void> {
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<OverlayHos
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({
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<MpvSubtitleRenderMetrics>);
},
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<Extension | null> {
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<void> {
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<BrowserWindow>({
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<SubsyncResult> {
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
}

View File

@@ -12,6 +12,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyTray: () => calls.push('destroy-tray'),
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']);
});

View File

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

View File

@@ -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: () => {},

View File

@@ -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(),

View File

@@ -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: () => {},
},
});

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => 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),

View File

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

View File

@@ -21,11 +21,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => void;
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
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),

View File

@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
createRestoreOverlayMpvSubtitlesHandler,
} from './overlay-mpv-sub-visibility';
type VisibilityState = {
savedSubVisibility: boolean | null;
savedSecondarySubVisibility: boolean | null;
revision: number;
};
test('ensure overlay mpv subtitle suppression captures previous visibility then hides subtitles', async () => {
const state: VisibilityState = {
savedSubVisibility: null,
savedSecondarySubVisibility: null,
revision: 0,
};
const calls: boolean[] = [];
const ensureHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (_name: string) => 'no',
}),
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
logWarn: () => {},
});
await ensureHidden();
assert.equal(state.savedSubVisibility, false);
assert.equal(state.savedSecondarySubVisibility, false);
assert.equal(state.revision, 1);
assert.deepEqual(calls, [false, false]);
});
test('restore overlay mpv subtitle suppression restores saved visibility', () => {
const state: VisibilityState = {
savedSubVisibility: false,
savedSecondarySubVisibility: true,
revision: 4,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => false,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, null);
assert.equal(state.savedSecondarySubVisibility, null);
assert.equal(state.revision, 5);
assert.deepEqual(calls, [false, true]);
});
test('restore keeps mpv subtitles hidden when visible-overlay binding still requires suppression', () => {
const state: VisibilityState = {
savedSubVisibility: true,
savedSecondarySubVisibility: true,
revision: 9,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => true,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, true);
assert.equal(state.savedSecondarySubVisibility, true);
assert.equal(state.revision, 10);
assert.deepEqual(calls, [false, false]);
});
test('restore defers mpv subtitle restore while mpv is disconnected', () => {
const state: VisibilityState = {
savedSubVisibility: true,
savedSecondarySubVisibility: false,
revision: 2,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getSavedSecondarySubVisibility: () => state.savedSecondarySubVisibility,
setSavedSecondarySubVisibility: (visible) => {
state.savedSecondarySubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => false,
shouldKeepSuppressedFromVisibleOverlayBinding: () => false,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
setMpvSecondarySubVisibility: (visible) => {
calls.push(visible);
},
});
restore();
assert.equal(state.savedSubVisibility, true);
assert.equal(state.revision, 3);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,147 @@
type MpvVisibilityClient = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type RestoreOptions = {
respectVisibleOverlayBinding?: boolean;
};
function parseSubVisibility(value: unknown): boolean {
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
return true;
}
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
return true;
}
export function createEnsureOverlayMpvSubtitlesHiddenHandler(deps: {
getMpvClient: () => MpvVisibilityClient | null;
getSavedSubVisibility: () => boolean | null;
setSavedSubVisibility: (visible: boolean | null) => void;
getSavedSecondarySubVisibility: () => boolean | null;
setSavedSecondarySubVisibility: (visible: boolean | null) => void;
getRevision: () => number;
setRevision: (revision: number) => void;
setMpvSubVisibility: (visible: boolean) => void;
setMpvSecondarySubVisibility: (visible: boolean) => void;
logWarn: (message: string, error: unknown) => void;
}) {
return async (): Promise<void> => {
const revision = deps.getRevision() + 1;
deps.setRevision(revision);
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return;
}
if (deps.getSavedSubVisibility() === null) {
try {
const currentSubVisibility = await mpvClient.requestProperty('sub-visibility');
if (revision !== deps.getRevision()) {
return;
}
deps.setSavedSubVisibility(parseSubVisibility(currentSubVisibility));
} catch (error) {
if (revision !== deps.getRevision()) {
return;
}
deps.logWarn(
'[overlay] Failed to capture mpv sub-visibility; falling back to visible restore',
error,
);
deps.setSavedSubVisibility(true);
}
}
if (deps.getSavedSecondarySubVisibility() === null) {
try {
const currentSecondarySubVisibility = await mpvClient.requestProperty('secondary-sub-visibility');
if (revision !== deps.getRevision()) {
return;
}
deps.setSavedSecondarySubVisibility(parseSubVisibility(currentSecondarySubVisibility));
} catch (error) {
if (revision !== deps.getRevision()) {
return;
}
deps.logWarn(
'[overlay] Failed to capture secondary mpv sub-visibility; falling back to visible restore',
error,
);
deps.setSavedSecondarySubVisibility(true);
}
}
if (revision !== deps.getRevision()) {
return;
}
deps.setMpvSubVisibility(false);
deps.setMpvSecondarySubVisibility(false);
};
}
export function createRestoreOverlayMpvSubtitlesHandler(deps: {
getSavedSubVisibility: () => boolean | null;
setSavedSubVisibility: (visible: boolean | null) => void;
getSavedSecondarySubVisibility: () => boolean | null;
setSavedSecondarySubVisibility: (visible: boolean | null) => void;
getRevision: () => number;
setRevision: (revision: number) => void;
isMpvConnected: () => boolean;
shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
setMpvSecondarySubVisibility: (visible: boolean) => void;
}) {
return (options?: RestoreOptions): void => {
deps.setRevision(deps.getRevision() + 1);
const savedVisibility = deps.getSavedSubVisibility();
const respectVisibleOverlayBinding = options?.respectVisibleOverlayBinding ?? true;
if (
respectVisibleOverlayBinding &&
deps.shouldKeepSuppressedFromVisibleOverlayBinding()
) {
deps.setMpvSubVisibility(false);
deps.setMpvSecondarySubVisibility(false);
return;
}
const hasSecondarySavedVisibility = deps.getSavedSecondarySubVisibility() !== null;
if (savedVisibility === null && !hasSecondarySavedVisibility) {
return;
}
if (!deps.isMpvConnected()) {
return;
}
if (savedVisibility !== null) {
deps.setMpvSubVisibility(savedVisibility);
}
const savedSecondaryVisibility = deps.getSavedSecondarySubVisibility();
if (savedSecondaryVisibility !== null) {
deps.setMpvSecondarySubVisibility(savedSecondaryVisibility);
}
deps.setSavedSubVisibility(null);
deps.setSavedSecondarySubVisibility(null);
};
}

View File

@@ -1,11 +1,9 @@
import assert from 'node:assert/strict';
import 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']);
});

View File

@@ -1,30 +1,27 @@
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(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<TWindow>(deps: {
}
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(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<TWindow>(deps: {
});
}
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setInvisibleWindow: deps.setInvisibleWindow,
});
}
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
setSecondaryWindow: (window: TWindow | null) => void;
}) {
return () => ({
createOverlayWindow: deps.createOverlayWindow,
setSecondaryWindow: deps.setSecondaryWindow,
});
}
export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setModalWindow: (window: TWindow | null) => void;
}) {
return () => ({

View File

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

View File

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

View File

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

View File

@@ -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<TWindow> = Parameters<
@@ -20,8 +16,6 @@ type CreateOverlayWindowMainDeps<TWindow> = Parameters<
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
setMainWindow: (window: TWindow | null) => void;
setInvisibleWindow: (window: TWindow | null) => void;
setSecondaryWindow: (window: TWindow | null) => void;
setModalWindow: (window: TWindow | null) => void;
}) {
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
@@ -33,18 +27,6 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
setMainWindow: (window) => deps.setMainWindow(window),
})(),
);
const createInvisibleWindow = createCreateInvisibleWindowHandler<TWindow>(
createBuildCreateInvisibleWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setInvisibleWindow: (window) => deps.setInvisibleWindow(window),
})(),
);
const createSecondaryWindow = createCreateSecondaryWindowHandler<TWindow>(
createBuildCreateSecondaryWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
})(),
);
const createModalWindow = createCreateModalWindowHandler<TWindow>(
createBuildCreateModalWindowMainDepsHandler<TWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
@@ -55,8 +37,6 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
return {
createOverlayWindow,
createMainWindow,
createInvisibleWindow,
createSecondaryWindow,
createModalWindow,
};
}

View File

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

View File

@@ -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<T> = (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<T>(
channel: string,
normalize: (payload: unknown) => T,
): (listener: PayloadedListener<T>) => void {
const pending: T[] = [];
const listeners: PayloadedListener<T>[] = [];
const dispatch = (payload: T): void => {
if (listeners.length === 0) {
pending.push(payload);
return;
}
for (const listener of listeners) {
listener(payload);
}
};
ipcRenderer.on(channel, (_event: IpcRendererEvent, payloadArg: unknown) => {
dispatch(normalize(payloadArg));
});
return (listener: PayloadedListener<T>): void => {
listeners.push(listener);
while (pending.length > 0) {
const payload = pending.shift();
listener(payload as T);
}
};
}
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload,
);
const onKikuFieldGroupingRequestEvent = createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer,
@@ -94,16 +161,6 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> =>
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<void>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
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<SubsyncResult> =>
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<KikuMergePreviewResponse> =>
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<ClipboardAppendResult> =>
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,

View File

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

View File

@@ -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 = {

View File

@@ -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): void {
});
}
registerModalOpenHandlers();
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
if (ctx.platform.isMacOSPlatform) {
@@ -252,41 +271,17 @@ async function init(): Promise<void> {
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<void> {
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<void> {
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');

View File

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

View File

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

View File

@@ -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<string, unknown>,
excludedKeys: ReadonlySet<string> = new Set<string>(),
): void {
for (const [key, value] of Object.entries(declarations)) {
if (excludedKeys.has(key)) {
continue;
}
if (value === null || value === undefined || typeof value === 'object') {
continue;
}
const cssValue = String(value);
if (key.startsWith('-') || key.includes('-')) {
target.style.setProperty(key, cssValue);
if (key === '--webkit-text-stroke') {
target.style.setProperty('-webkit-text-stroke', cssValue);
}
continue;
}
const styleTarget = target.style as unknown as Record<string, string>;
styleTarget[key] = cssValue;
}
}
function pickInlineStyleDeclarations(
declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>,
): Record<string, unknown> {
const picked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(declarations)) {
if (!includedKeys.has(key)) continue;
picked[key] = value;
}
return picked;
}
const CONTAINER_STYLE_KEYS = new Set<string>([
'background',
'backgroundColor',
'backdropFilter',
'WebkitBackdropFilter',
'webkitBackdropFilter',
'-webkit-backdrop-filter',
]);
function getFrequencyDictionaryClass(
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<string, unknown>;
applyInlineStyleDeclarations(
ctx.dom.subtitleRoot,
styleDeclarations,
CONTAINER_STYLE_KEYS,
);
applyInlineStyleDeclarations(
ctx.dom.subtitleContainer,
pickInlineStyleDeclarations(styleDeclarations, CONTAINER_STYLE_KEYS),
);
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
if (style.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<string, unknown>;
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 {

View File

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

View File

@@ -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',

View File

@@ -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<InvisibleOverlayConfig>;
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<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
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<SubtitlePosition | null>;
@@ -781,8 +770,8 @@ export interface ElectronAPI {
onOpenJimaku: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
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;
}