mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat: bind overlay state to secondary subtitle mpv visibility
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
55
docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
Normal file
55
docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
Normal 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.
|
||||
@@ -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\"",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> = [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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', []]]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
307
src/main.ts
307
src/main.ts
@@ -218,7 +218,6 @@ import {
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler,
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||
createOverlayWindowRuntimeHandlers,
|
||||
createOverlayRuntimeBootstrapHandlers,
|
||||
@@ -234,7 +233,6 @@ import {
|
||||
createSetOverlayDebugVisualizationEnabledHandler,
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateInvisibleOverlayBoundsHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
createLoadSubtitlePositionHandler,
|
||||
createSaveSubtitlePositionHandler,
|
||||
@@ -356,16 +354,16 @@ import {
|
||||
runStartupBootstrapRuntime,
|
||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||
sendMpvCommandRuntime,
|
||||
setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore,
|
||||
setMpvSecondarySubVisibilityRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
syncOverlayWindowLayer,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
showMpvOsdRuntime,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
} from './core/services';
|
||||
import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry';
|
||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
@@ -376,7 +374,10 @@ import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options
|
||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
|
||||
import { createMainRuntimeRegistry } from './main/runtime/registry';
|
||||
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight';
|
||||
import {
|
||||
createEnsureOverlayMpvSubtitlesHiddenHandler,
|
||||
createRestoreOverlayMpvSubtitlesHandler,
|
||||
} from './main/runtime/overlay-mpv-sub-visibility';
|
||||
import {
|
||||
composeAnilistSetupHandlers,
|
||||
composeAnilistTrackingHandlers,
|
||||
@@ -644,7 +645,6 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
|
||||
});
|
||||
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
createModalWindow: () => createModalWindow(),
|
||||
getModalGeometry: () => getCurrentOverlayGeometry(),
|
||||
@@ -725,13 +725,70 @@ async function initializeDiscordPresenceService(): Promise<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,
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
171
src/main/runtime/overlay-mpv-sub-visibility.test.ts
Normal file
171
src/main/runtime/overlay-mpv-sub-visibility.test.ts
Normal 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, []);
|
||||
});
|
||||
147
src/main/runtime/overlay-mpv-sub-visibility.ts
Normal file
147
src/main/runtime/overlay-mpv-sub-visibility.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
133
src/preload.ts
133
src/preload.ts
@@ -45,7 +45,6 @@ import type {
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
MpvSubtitleRenderMetrics,
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
@@ -55,12 +54,80 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
|
||||
const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
|
||||
const overlayLayer =
|
||||
overlayLayerFromArg === 'visible' ||
|
||||
overlayLayerFromArg === 'invisible' ||
|
||||
overlayLayerFromArg === 'secondary' ||
|
||||
overlayLayerFromArg === 'modal'
|
||||
? overlayLayerFromArg
|
||||
: null;
|
||||
overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'modal' ? overlayLayerFromArg : null;
|
||||
|
||||
type EmptyListener = () => void;
|
||||
type PayloadedListener<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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -339,9 +401,6 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
|
||||
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
19
src/types.ts
19
src/types.ts
@@ -71,8 +71,6 @@ export interface WindowGeometry {
|
||||
|
||||
export interface SubtitlePosition {
|
||||
yPercent: number;
|
||||
invisibleOffsetXPx?: number;
|
||||
invisibleOffsetYPx?: number;
|
||||
}
|
||||
|
||||
export interface SubtitleStyle {
|
||||
@@ -272,6 +270,7 @@ export interface SubtitleStyleConfig {
|
||||
enableJlpt?: boolean;
|
||||
preserveLineBreaks?: boolean;
|
||||
hoverTokenColor?: string;
|
||||
hoverTokenBackgroundColor?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
@@ -309,7 +308,6 @@ export type FrequencyDictionaryMode = 'single' | 'banded';
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
toggleVisibleOverlayGlobal?: string | null;
|
||||
toggleInvisibleOverlayGlobal?: string | null;
|
||||
copySubtitle?: string | null;
|
||||
copySubtitleMultiple?: string | null;
|
||||
updateLastCardFromClipboard?: string | null;
|
||||
@@ -364,10 +362,6 @@ export interface DiscordPresenceConfig {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export interface InvisibleOverlayConfig {
|
||||
startupVisibility?: 'platform-default' | 'visible' | 'hidden';
|
||||
}
|
||||
|
||||
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
|
||||
|
||||
export interface YoutubeSubgenConfig {
|
||||
@@ -410,7 +404,6 @@ export interface Config {
|
||||
anilist?: AnilistConfig;
|
||||
jellyfin?: JellyfinConfig;
|
||||
discordPresence?: DiscordPresenceConfig;
|
||||
invisibleOverlay?: InvisibleOverlayConfig;
|
||||
youtubeSubgen?: YoutubeSubgenConfig;
|
||||
immersionTracking?: ImmersionTrackingConfig;
|
||||
logging?: {
|
||||
@@ -540,7 +533,6 @@ export interface ResolvedConfig {
|
||||
updateIntervalMs: number;
|
||||
debounceMs: number;
|
||||
};
|
||||
invisibleOverlay: Required<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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user