17 Commits

Author SHA1 Message Date
dde51f8634 fix(overlay): honor mpv subtitle binding config and tidy modal close 2026-02-27 00:45:15 -08:00
77c698e00b fix(overlay): tolerate minimal webContents in bridge send path 2026-02-27 00:39:59 -08:00
edca554db1 small fixes 2026-02-27 00:34:34 -08:00
edcd5cddb6 change default plugin options
enable auto_start and auto_start_visible_overlay
2026-02-27 00:34:24 -08:00
a2551016cd fix(plugin): honor auto-start and retry visible overlay startup action 2026-02-27 00:23:24 -08:00
3e9db1f125 feat(config): refresh subtitle style defaults and drop plugin legacy startup alias 2026-02-27 00:03:07 -08:00
bc6f581ea5 fix: address claude review action items 2026-02-26 23:27:25 -08:00
d4805395fa fix: suppress mpv primary subtitles when visible overlay is enabled 2026-02-26 19:29:51 -08:00
10a92f100a fix: type annotate overlay runtime test mock callbacks 2026-02-26 18:48:16 -08:00
a03388a38f fix: address claude review feedback on overlay refactor 2026-02-26 18:47:51 -08:00
75442a4648 feat: bind overlay state to secondary subtitle mpv visibility 2026-02-26 16:40:51 -08:00
74554a30f0 refactor: remove invisible subtitle overlay code 2026-02-26 16:40:46 -08:00
643f8eb958 test(main): add overlay modal runtime fallback open-state regression coverage 2026-02-26 12:54:50 -08:00
a14c9da139 fix(main): restore modal pointer events after fallback reveal when open confirmed 2026-02-26 12:54:48 -08:00
ad97948062 fix(renderer): calibrate macOS invisible overlay spacing 2026-02-25 20:57:04 -08:00
efaf9a78cd fix(renderer): calibrate invisible overlay metrics and hover mapping 2026-02-25 00:44:25 -08:00
058d359553 fix(renderer): tighten macOS invisible overlay multiline line height 2026-02-25 00:31:39 -08:00
172 changed files with 2477 additions and 3646 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop .PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
APP_NAME := subminer APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi THEME_SOURCE := assets/themes/subminer.rasi
@@ -53,6 +53,8 @@ help:
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \ " clean Remove build artifacts (dist/, release/, AppImage, binary)" \
" dev-start Build and launch local Electron app" \ " dev-start Build and launch local Electron app" \
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \ " dev-start-macos Build and launch local Electron app with macOS tracker backend" \
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
" dev-toggle Toggle overlay in a running local Electron app" \ " dev-toggle Toggle overlay in a running local Electron app" \
" dev-stop Stop a running local Electron app" \ " dev-stop Stop a running local Electron app" \
" docs-dev Run VitePress docs dev server" \ " docs-dev Run VitePress docs dev server" \
@@ -173,6 +175,12 @@ dev-start-macos: ensure-bun
@bun run build @bun run build
@bun run electron . --start --backend macos @bun run electron . --start --backend macos
dev-watch: ensure-bun
@bash scripts/dev-watch.sh
dev-watch-macos: ensure-bun
@bash scripts/dev-watch.sh --start --dev --backend macos
dev-toggle: ensure-bun dev-toggle: ensure-bun
@bun run electron . --toggle @bun run electron . --toggle

View File

@@ -73,7 +73,7 @@ subminer app --start --yomitan
```bash ```bash
subminer app --start --background subminer app --start --background
subminer video.mkv # toggle invisible overlay with y-i and visible overlay with y-t subminer video.mkv # y-t toggles overlay visibility
``` ```
## Requirements ## Requirements

View File

@@ -17,7 +17,7 @@
// Control whether visible overlay toggles also toggle MPV subtitle visibility. // Control whether visible overlay toggles also toggle MPV subtitle visibility.
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
// ========================================== // ==========================================
"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, // Link visible overlay toggles to MPV primary subtitle visibility. Values: true | false
// ========================================== // ==========================================
// Texthooker Server // Texthooker Server
@@ -53,7 +53,6 @@
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting. "copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
@@ -68,16 +67,6 @@
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting. "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
// Invisible Overlay
// Startup behavior for the invisible interactive subtitle mining layer.
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default" // Startup visibility setting.
}, // Startup behavior for the invisible interactive subtitle mining layer.
// ========================================== // ==========================================
// Keybindings (MPV Commands) // Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults. // Extra keybindings that are merged with built-in defaults.
@@ -125,13 +114,21 @@
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv. "hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"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. "hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting. "fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
"fontWeight": "normal", // Font weight setting. "fontWeight": "600", // Font weight setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
"fontStyle": "normal", // Font style setting. "fontStyle": "normal", // Font style setting.
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting. "backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting. "nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting. "knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": { "jlptColors": {
@@ -156,12 +153,19 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontFamily": "Manrope, Inter", // Font family setting.
"fontSize": 24, // Font size setting. "fontSize": 24, // Font size setting.
"fontColor": "#ffffff", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
"backgroundColor": "transparent", // Background color setting. "backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight setting. "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting. "fontStyle": "normal" // Font style setting.
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
} // Secondary setting. } // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.

View File

@@ -16,7 +16,7 @@ make docs-preview # Preview built site at http://localhost:4173
- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup - [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup
- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings - [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings
- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, overlay layers, card creation - [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, single overlay + modals, card creation
### Reference ### Reference

View File

@@ -78,7 +78,7 @@ src/
### Service Layer (`src/core/services/`) ### Service Layer (`src/core/services/`)
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-window-geometry.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts` - **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts`
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts` - **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts` - **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts` - **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
@@ -102,7 +102,6 @@ src/renderer/
positioning.ts # Facade export for positioning controller positioning.ts # Facade export for positioning controller
positioning/ positioning/
controller.ts # Position controller orchestration controller.ts # Position controller orchestration
invisible-layout*.ts # Invisible layer layout computations
position-state.ts # Position state helpers position-state.ts # Position state helpers
handlers/ handlers/
keyboard.ts # Keybindings, chord handling, modal key routing keyboard.ts # Keybindings, chord handling, modal key routing
@@ -125,7 +124,7 @@ src/renderer/
## Flow Diagram ## Flow Diagram
The main process has three layers: `main.ts` delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough. The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -162,7 +161,7 @@ flowchart LR
subgraph Svc["Services — src/core/services/"] subgraph Svc["Services — src/core/services/"]
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc Overlay["Overlay Manager<br/>window · visibility · bridge"]:::svc
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
@@ -172,9 +171,7 @@ flowchart LR
Bridge(["preload.ts<br/>Electron IPC"]):::bridge Bridge(["preload.ts<br/>Electron IPC"]):::bridge
subgraph Rend["Renderer — src/renderer/"] subgraph Rend["Renderer — src/renderer/"]
Visible["Visible window<br/>Yomitan lookups"]:::rend Overlay["Main overlay window<br/>primary + secondary subtitles"]:::rend
Invisible["Invisible window<br/>mpv positioning"]:::rend
Secondary["Secondary window<br/>subtitle bar"]:::rend
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
end end
@@ -193,10 +190,8 @@ flowchart LR
DiscordExt <-->|"RPC"| Integrations DiscordExt <-->|"RPC"| Integrations
Overlay & Mining --> Bridge Overlay & Mining --> Bridge
Bridge --> Visible Bridge --> Overlay
Bridge --> Invisible Overlay --> UI
Bridge --> Secondary
Visible & Invisible & Secondary --> UI
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5 style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5 style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
@@ -264,9 +259,9 @@ For domains migrated to reducer-style transitions (for example AniList token/que
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend. - **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks. - **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`. - **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
- **Overlay runtime:** `initializeOverlayRuntime()` creates three overlay windows — **visible** (interactive Yomitan lookups), **invisible** (mpv-matched subtitle positioning), and **secondary** (secondary subtitle bar, top 20% via `splitOverlayGeometryForSecondaryBar`) — then registers global shortcuts and sets initial bounds from the window tracker. - **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering) and registers global shortcuts and bounds tracking via the active window tracker.
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh. - **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh.
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results broadcast to all overlay windows. - **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state. - **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state.
```mermaid ```mermaid
@@ -298,14 +293,10 @@ flowchart LR
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
VisWin --> Warmups MainWin --> Warmups
InvWin --> Warmups
SecWin --> Warmups
Shortcuts --> Warmups Shortcuts --> Warmups
Warmups["Background<br/>warmups"]:::phase Warmups["Background<br/>warmups"]:::phase
@@ -330,7 +321,7 @@ flowchart LR
ExtEvt["Shortcuts · config hot-reload"]:::runtime ExtEvt["Shortcuts · config hot-reload"]:::runtime
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
end end
WarmupGroup --> Loop WarmupGroup --> Loop

View File

@@ -74,7 +74,7 @@ The configuration file includes several main sections:
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection - [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility - [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer - [**Subtitle Position Edit**](#subtitle-position-edit) - Fine-tune subtitle alignment in overlay
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
@@ -338,7 +338,7 @@ Control whether the overlay automatically becomes visible when it connects to mp
| -------------------- | --------------- | ------------------------------------------------------ | | -------------------- | --------------- | ------------------------------------------------------ |
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
The mpv plugin controls startup per layer via `auto_start_visible_overlay` and `auto_start_invisible_overlay` in `subminer.conf` (`platform-default` for invisible means hidden on Linux, visible on macOS/Windows). The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
### Visible Overlay Subtitle Binding ### Visible Overlay Subtitle Binding
@@ -379,20 +379,12 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable. Customize it there, or set it to `null` to disable.
### Invisible Overlay ### Subtitle Position Edit
SubMiner includes a second subtitle mining layer that can be visually invisible while still interactive for Yomitan lookups. Subtitle positioning can be adjusted directly in the overlay:
- `invisibleOverlay.startupVisibility` values:
1. `"platform-default"`: hidden on Wayland, visible on Windows/macOS/other sessions.
2. `"visible"`: always shown on startup.
3. `"hidden"`: always hidden on startup.
Invisible subtitle positioning can be adjusted directly in the invisible layer:
- `Ctrl/Cmd+Shift+P` toggles position edit mode. - `Ctrl/Cmd+Shift+P` toggles position edit mode.
- Use arrow keys to move the invisible subtitle text. - Use arrow keys to move subtitle text.
- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel. - Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel.
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`). - This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
@@ -670,7 +662,6 @@ See `config.example.jsonc` for detailed configuration options.
{ {
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", "toggleVisibleOverlayGlobal": "Alt+Shift+O",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
"copySubtitle": "CommandOrControl+C", "copySubtitle": "CommandOrControl+C",
"copySubtitleMultiple": "CommandOrControl+Shift+C", "copySubtitleMultiple": "CommandOrControl+Shift+C",
"updateLastCardFromClipboard": "CommandOrControl+V", "updateLastCardFromClipboard": "CommandOrControl+V",
@@ -689,7 +680,6 @@ See `config.example.jsonc` for detailed configuration options.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `toggleInvisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling invisible interactive overlay (default: `"Alt+Shift+I"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | | `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
@@ -734,15 +724,23 @@ See `config.example.jsonc` for detailed configuration options.
```json ```json
{ {
"subtitleStyle": { "subtitleStyle": {
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
"fontSize": 35, "fontSize": 35,
"fontColor": "#cad3f5", "fontColor": "#cad3f5",
"fontWeight": "normal", "fontWeight": "600",
"lineHeight": 1.35,
"letterSpacing": "-0.01em",
"wordSpacing": 0,
"fontKerning": "normal",
"textRendering": "geometricPrecision",
"textShadow": "0 3px 10px rgba(0,0,0,0.69)",
"fontStyle": "normal", "fontStyle": "normal",
"backgroundColor": "rgb(30, 32, 48, 0.88)", "backgroundColor": "rgb(30, 32, 48, 0.88)",
"backdropFilter": "blur(6px)",
"secondary": { "secondary": {
"fontFamily": "Manrope, Inter",
"fontSize": 24, "fontSize": 24,
"fontColor": "#ffffff", "fontColor": "#cad3f5",
"backgroundColor": "transparent" "backgroundColor": "transparent"
} }
} }
@@ -751,10 +749,10 @@ See `config.example.jsonc` for detailed configuration options.
| Option | Values | Description | | Option | Values | Description |
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
| `fontFamily` | string | CSS font-family value (default: `"Noto Sans CJK JP Regular, ..."`) | | `fontFamily` | string | CSS font-family value (default: `"M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) | | `fontSize` | number (px) | Font size in pixels (default: `35`) |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) | | `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"normal"`) | | `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) | | `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) | | `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
@@ -782,7 +780,7 @@ Lookup behavior:
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window. In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
Secondary subtitle defaults: `fontSize: 24`, `fontColor: "#ffffff"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults. Secondary subtitle defaults: `fontFamily: "Manrope, Inter"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
**See `config.example.jsonc`** for the complete list of subtitle style configuration options. **See `config.example.jsonc`** for the complete list of subtitle style configuration options.

View File

@@ -60,6 +60,15 @@ bun run dev # builds + launches with --start --dev
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
electron . --background # tray/background mode, minimal default logging electron . --background # tray/background mode, minimal default logging
make dev-start # build + launch via Makefile make dev-start # build + launch via Makefile
make dev-watch # watch TS + renderer and launch Electron (faster edit loop)
make dev-watch-macos # same as dev-watch, forcing --backend macos
```
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
```ini
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
``` ```
## Testing ## Testing

View File

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

View File

@@ -181,9 +181,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `y-i` | Toggle invisible overlay |
| `y-I` | Show invisible overlay |
| `y-u` | Hide invisible overlay |
| `y-o` | Open Yomitan settings | | `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check overlay status | | `y-c` | Check overlay status |

View File

@@ -24,11 +24,11 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization:
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes. This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
## The Three Overlay Planes ## Overlay Model
SubMiner uses three overlay planes, each serving a different purpose. SubMiner uses one overlay window with modal surfaces.
### Visible Overlay ### Primary Subtitle Layer
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports: The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
@@ -38,31 +38,17 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options - Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
- **N+1 highlighting** — known words from your Anki deck are visually highlighted - **N+1 highlighting** — known words from your Anki deck are visually highlighted
Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin). Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
### Secondary Subtitle Plane ### Secondary Subtitle Bar
The secondary plane is a compact top-strip layer for translation and context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden. The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
It is controlled by `secondarySub` configuration and shares lifecycle with the overlay stack. It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
### Invisible Overlay ### Modal Surfaces
The invisible overlay is a transparent layer aligned with mpv's own subtitle rendering. It uses mpv's subtitle metrics (font size, margins, position, scaling) to map click targets accurately. Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
This layer still supports:
- Word-level click-through lookups over the text region
- Optional manual position fine-tuning in pixel mode
- Independent toggle behavior with global shortcuts
Position edit mode is available via `Ctrl/Cmd+Shift+P`, then arrow keys / `hjkl` to nudge position; `Shift` moves faster. Save with `Enter` or `Ctrl+S`, cancel with `Esc`.
Toggle controls:
- `Alt+Shift+O` / `y-t`: visible overlay
- `Alt+Shift+I` / `y-i`: invisible overlay
- Secondary plane visibility is controlled via `secondarySub` config and matching global shortcuts.
## Looking Up Words ## Looking Up Words
@@ -73,10 +59,10 @@ Toggle controls:
3. Yomitan detects the text selection and opens its popup with dictionary results. 3. Yomitan detects the text selection and opens its popup with dictionary results.
4. From the Yomitan popup, you can add the word directly to Anki. 4. From the Yomitan popup, you can add the word directly to Anki.
### On the Invisible Overlay ### On Overlay Subtitles
1. The invisible layer sits over mpv's own subtitle text. 1. Subtitles are rendered directly in the overlay.
2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text. 2. Click on any word in the subtitle.
3. On macOS, word selection happens automatically on hover. 3. On macOS, word selection happens automatically on hover.
4. Yomitan popup appears for lookup and card creation. 4. Yomitan popup appears for lookup and card creation.

View File

@@ -33,9 +33,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `y-i` | Toggle invisible overlay |
| `y-I` | Show invisible overlay |
| `y-u` | Hide invisible overlay |
| `y-o` | Open settings window | | `y-o` | Open settings window |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check status | | `y-c` | Check status |
@@ -50,10 +47,9 @@ SubMiner:
1. Start overlay 1. Start overlay
2. Stop overlay 2. Stop overlay
3. Toggle overlay 3. Toggle overlay
4. Toggle invisible overlay 4. Open options
5. Open options 5. Restart overlay
6. Restart overlay 6. Check status
7. Check status
``` ```
Select an item by pressing its number. Select an item by pressing its number.
@@ -84,10 +80,6 @@ auto_start=no
# Show the visible overlay on auto-start. # Show the visible overlay on auto-start.
auto_start_visible_overlay=no auto_start_visible_overlay=no
# Invisible overlay startup: platform-default, visible, hidden.
# platform-default = hidden on Linux, visible on macOS/Windows.
auto_start_invisible_overlay=platform-default
# Show OSD messages for overlay status changes. # Show OSD messages for overlay status changes.
osd_messages=yes osd_messages=yes
@@ -129,7 +121,6 @@ aniskip_button_duration=3
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend | | `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load | | `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load |
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start | | `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start |
| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | | `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | | `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection | | `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
@@ -182,9 +173,6 @@ The plugin can be controlled from other mpv scripts or the mpv command line usin
script-message subminer-start script-message subminer-start
script-message subminer-stop script-message subminer-stop
script-message subminer-toggle script-message subminer-toggle
script-message subminer-toggle-invisible
script-message subminer-show-invisible
script-message subminer-hide-invisible
script-message subminer-menu script-message subminer-menu
script-message subminer-options script-message subminer-options
script-message subminer-restart script-message subminer-restart

View File

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

View File

@@ -17,7 +17,7 @@
// Control whether visible overlay toggles also toggle MPV subtitle visibility. // Control whether visible overlay toggles also toggle MPV subtitle visibility.
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
// ========================================== // ==========================================
"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, // Link visible overlay toggles to MPV primary subtitle visibility. Values: true | false
// ========================================== // ==========================================
// Texthooker Server // Texthooker Server
@@ -53,7 +53,6 @@
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting. "copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
@@ -68,16 +67,6 @@
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting. "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
// Invisible Overlay
// Startup behavior for the invisible interactive subtitle mining layer.
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default" // Startup visibility setting.
}, // Startup behavior for the invisible interactive subtitle mining layer.
// ========================================== // ==========================================
// Keybindings (MPV Commands) // Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults. // Extra keybindings that are merged with built-in defaults.
@@ -125,13 +114,21 @@
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv. "hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"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. "hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting. "fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
"fontWeight": "normal", // Font weight setting. "fontWeight": "600", // Font weight setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
"fontStyle": "normal", // Font style setting. "fontStyle": "normal", // Font style setting.
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting. "backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting. "nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting. "knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": { "jlptColors": {
@@ -156,12 +153,19 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontFamily": "Manrope, Inter", // Font family setting.
"fontSize": 24, // Font size setting. "fontSize": 24, // Font size setting.
"fontColor": "#ffffff", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
"backgroundColor": "transparent", // Background color setting. "backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight setting. "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting. "fontStyle": "normal" // Font style setting.
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
} // Secondary setting. } // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.

View File

@@ -9,7 +9,6 @@ These work system-wide regardless of which window has focus.
| Shortcut | Action | Configurable | | Shortcut | Action | Configurable |
| ------------- | ------------------------ | ---------------------------------------- | | ------------- | ------------------------ | ---------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` | | `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+I` | Toggle invisible overlay | `shortcuts.toggleInvisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) | | `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
::: tip ::: tip
@@ -64,9 +63,9 @@ These keybindings can be overridden or disabled via the `keybindings` config arr
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
## Invisible Subtitle Position Edit Mode ## Subtitle Position Edit Mode
Enter edit mode to fine-tune invisible overlay alignment with mpv's native subtitles. Enter edit mode to fine-tune subtitle alignment.
| Shortcut | Action | | Shortcut | Action |
| --------------------- | -------------------------------- | | --------------------- | -------------------------------- |
@@ -86,9 +85,6 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `y-i` | Toggle invisible overlay |
| `y-I` | Show invisible overlay |
| `y-u` | Hide invisible overlay |
| `y-o` | Open Yomitan settings | | `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check overlay status | | `y-c` | Check overlay status |
@@ -112,7 +108,6 @@ All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electro
"mineSentence": "CommandOrControl+S", "mineSentence": "CommandOrControl+S",
"copySubtitle": "CommandOrControl+C", "copySubtitle": "CommandOrControl+C",
"toggleVisibleOverlayGlobal": "Alt+Shift+O", "toggleVisibleOverlayGlobal": "Alt+Shift+O",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
"openJimaku": null, // disabled "openJimaku": null, // disabled
}, },
} }

View File

@@ -153,7 +153,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
- Sway: Ensure `swaymsg` is available. - Sway: Ensure `swaymsg` is available.
- X11: Ensure `xdotool` and `xwininfo` are installed. - X11: Ensure `xdotool` and `xwininfo` are installed.
If the overlay position is slightly off, use invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`. If the overlay position is slightly off, use subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`.
## Yomitan ## Yomitan
@@ -217,10 +217,10 @@ Media generation has a 30-second timeout (60 seconds for animated AVIF). If your
**"Failed to register global shortcut"** **"Failed to register global shortcut"**
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+I`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings. Global shortcuts (`Alt+Shift+O`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
- Check your DE/WM keybinding settings for conflicts. - Check your DE/WM keybinding settings for conflicts.
- Change the shortcuts in your config under `shortcuts.toggleVisibleOverlayGlobal`, `shortcuts.toggleInvisibleOverlayGlobal`. - Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`.
- On Wayland, global shortcut registration has limitations depending on the compositor. - On Wayland, global shortcut registration has limitations depending on the compositor.
**Overlay keybindings not working** **Overlay keybindings not working**
@@ -273,5 +273,5 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
### macOS ### macOS
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility. - **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust the invisible subtitle offset. - **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset in position edit mode.
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app` - **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`

View File

@@ -5,7 +5,7 @@ There are two ways to use SubMiner — the `subminer` wrapper script or the mpv
| Approach | Best For | | Approach | Best For |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). | | **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). |
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control visible and invisible overlay layers. Requires `--input-ipc-server=/tmp/subminer-socket`. | | **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow. You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
@@ -68,11 +68,8 @@ SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window) SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --stop # Stop overlay SubMiner.AppImage --stop # Stop overlay
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
SubMiner.AppImage --start --toggle-invisible-overlay # Start MPV IPC + toggle invisible layer
SubMiner.AppImage --show-visible-overlay # Force show visible overlay SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --show-invisible-overlay # Force show invisible overlay
SubMiner.AppImage --hide-invisible-overlay # Force hide invisible overlay
SubMiner.AppImage --start --dev # Enable app/dev mode only SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
@@ -173,7 +170,6 @@ Notes:
| Keybind | Action | | Keybind | Action |
| ------------- | ------------------------ | | ------------- | ------------------------ |
| `Alt+Shift+O` | Toggle visible overlay | | `Alt+Shift+O` | Toggle visible overlay |
| `Alt+Shift+I` | Toggle invisible overlay |
| `Alt+Shift+Y` | Open Yomitan settings | | `Alt+Shift+Y` | Open Yomitan settings |
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config. `Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
@@ -195,10 +191,10 @@ Notes:
| `Ctrl+W` | Quit mpv | | `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle MPV pause (outside subtitle area) | | `Right-click` | Toggle MPV pause (outside subtitle area) |
| `Right-click + drag` | Move subtitle position (on subtitle) | | `Right-click + drag` | Move subtitle position (on subtitle) |
| `Ctrl/Cmd+Shift+P` | Toggle invisible subtitle position edit mode | | `Ctrl/Cmd+Shift+P` | Toggle subtitle position edit mode |
| `Arrow keys` | Move invisible subtitles while edit mode is active | | `Arrow keys` | Move subtitles while edit mode is active |
| `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode | | `Enter` / `Ctrl+S` | Save subtitle position in edit mode |
| `Esc` | Cancel invisible subtitle position edit mode | | `Esc` | Cancel subtitle position edit mode |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.

View File

@@ -21,8 +21,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",

View File

@@ -21,18 +21,10 @@ texthooker_port=5174
backend=auto backend=auto
# Automatically start overlay when a file is loaded # Automatically start overlay when a file is loaded
auto_start=no auto_start=yes
# Automatically show visible overlay when overlay starts # Automatically show visible overlay when overlay starts
auto_start_visible_overlay=no auto_start_visible_overlay=yes
# Automatically show invisible overlay when overlay starts
# Values: platform-default, visible, hidden
# platform-default => hidden on Linux, visible on macOS/Windows
auto_start_invisible_overlay=platform-default
# Legacy alias (maps to auto_start_visible_overlay)
# auto_start_overlay=no
# Show OSD messages for overlay status # Show OSD messages for overlay status
osd_messages=yes osd_messages=yes
@@ -70,4 +62,3 @@ aniskip_button_duration=3
# MPV keybindings provided by plugin/subminer.lua: # MPV keybindings provided by plugin/subminer.lua:
# y-s start, y-S stop, y-t toggle visible overlay # y-s start, y-S stop, y-t toggle visible overlay
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay

View File

@@ -24,10 +24,6 @@ local function default_socket_path()
return "/tmp/subminer-socket" return "/tmp/subminer-socket"
end end
local function is_linux()
return not is_windows() and not is_macos()
end
local function is_subminer_process_running() local function is_subminer_process_running()
local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" } local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" }
local result = mp.command_native({ local result = mp.command_native({
@@ -138,9 +134,7 @@ local opts = {
texthooker_port = 5174, texthooker_port = 5174,
backend = "auto", backend = "auto",
auto_start = true, auto_start = true,
auto_start_overlay = false, -- legacy alias, maps to auto_start_visible_overlay
auto_start_visible_overlay = false, auto_start_visible_overlay = false,
auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden
osd_messages = true, osd_messages = true,
log_level = "info", log_level = "info",
aniskip_enabled = true, aniskip_enabled = true,
@@ -163,7 +157,6 @@ local state = {
binary_available = false, binary_available = false,
binary_path = nil, binary_path = nil,
detected_backend = nil, detected_backend = nil,
invisible_overlay_visible = false,
hover_highlight = { hover_highlight = {
revision = -1, revision = -1,
payload = nil, payload = nil,
@@ -185,6 +178,10 @@ local state = {
}, },
} }
local STARTUP_OVERLAY_ACTION_DELAY_SECONDS = 0.6
local STARTUP_OVERLAY_ACTION_RETRY_DELAY_SECONDS = 0.4
local STARTUP_OVERLAY_ACTION_MAX_ATTEMPTS = 8
local HOVER_MESSAGE_NAME = "subminer-hover-token" local HOVER_MESSAGE_NAME = "subminer-hover-token"
local HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token" local HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token"
local DEFAULT_HOVER_BASE_COLOR = "FFFFFF" local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
@@ -796,6 +793,15 @@ local function fix_ass_color(input, fallback)
return b .. g .. r return b .. g .. r
end end
local function sanitize_hover_ass_color(input, fallback_rgb)
local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR)
local converted = fix_ass_color(input, fallback)
if converted == "000000" then
return fallback
end
return converted
end
local function escape_ass_text(text) local function escape_ass_text(text)
return (text or "") return (text or "")
:gsub("\\", "\\\\") :gsub("\\", "\\\\")
@@ -858,7 +864,7 @@ local function resolve_metrics()
border = sub_border_size * window_scale, border = sub_border_size * window_scale,
shadow = sub_shadow_offset * window_scale, shadow = sub_shadow_offset * window_scale,
base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR), base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
hover_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_COLOR), hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR),
} }
end end
@@ -1068,7 +1074,7 @@ local function build_hover_subtitle_content(payload)
end end
local metrics = resolve_metrics() local metrics = resolve_metrics()
local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color) local hover_color = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_HOVER_COLOR)
local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color) local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color) return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
end end
@@ -1435,47 +1441,48 @@ local function parse_start_script_message_overrides(...)
end end
local function resolve_visible_overlay_startup() local function resolve_visible_overlay_startup()
local visible = coerce_bool(opts.auto_start_visible_overlay, false) return coerce_bool(opts.auto_start_visible_overlay, false)
-- Backward compatibility for old config key.
if coerce_bool(opts.auto_start_overlay, false) then
visible = true
end
return visible
end
local function resolve_invisible_overlay_startup()
local raw = opts.auto_start_invisible_overlay
if type(raw) == "boolean" then
return raw
end
local mode = type(raw) == "string" and raw:lower() or "platform-default"
if mode == "visible" or mode == "show" or mode == "yes" or mode == "true" or mode == "on" then
return true
end
if mode == "hidden" or mode == "hide" or mode == "no" or mode == "false" or mode == "off" then
return false
end
-- platform-default
return not is_linux()
end end
local function apply_startup_overlay_preferences() local function apply_startup_overlay_preferences()
local should_show_visible = resolve_visible_overlay_startup() local should_show_visible = resolve_visible_overlay_startup()
local should_show_invisible = resolve_invisible_overlay_startup()
local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay" local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay"
if not run_control_command(visible_action) then local function try_apply(attempt)
subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action) if run_control_command(visible_action) then
subminer_log(
"debug",
"process",
"Applied visible startup action: " .. visible_action .. " (attempt " .. tostring(attempt) .. ")"
)
return
end
if attempt >= STARTUP_OVERLAY_ACTION_MAX_ATTEMPTS then
subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action)
return
end
mp.add_timeout(STARTUP_OVERLAY_ACTION_RETRY_DELAY_SECONDS, function()
try_apply(attempt + 1)
end)
end end
local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay" try_apply(1)
if not run_control_command(invisible_action) then end
subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action)
end
state.invisible_overlay_visible = should_show_invisible local function refresh_subminer_runtime_state()
state.binary_path = find_binary()
if state.binary_path then
state.binary_available = true
subminer_log("debug", "lifecycle", "SubMiner binary ready: " .. state.binary_path)
else
state.binary_available = false
subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled")
if opts.binary_path ~= "" then
subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist")
end
end
end end
local function build_texthooker_args() local function build_texthooker_args()
@@ -1577,7 +1584,7 @@ local function start_overlay(overrides)
end) end)
-- Apply explicit startup visibility for each overlay layer. -- Apply explicit startup visibility for each overlay layer.
mp.add_timeout(0.6, function() mp.add_timeout(STARTUP_OVERLAY_ACTION_DELAY_SECONDS, function()
apply_startup_overlay_preferences() apply_startup_overlay_preferences()
end) end)
end end
@@ -1646,90 +1653,6 @@ local function toggle_overlay()
end end
end end
local function toggle_invisible_overlay()
if not ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("toggle-invisible-overlay")
subminer_log("info", "process", "Toggling invisible overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
subminer_log("warn", "process", "Invisible toggle command failed")
show_osd("Invisible toggle failed")
return
end
state.invisible_overlay_visible = not state.invisible_overlay_visible
show_osd("Invisible overlay: " .. (state.invisible_overlay_visible and "visible" or "hidden"))
end
local function show_invisible_overlay()
if not ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("show-invisible-overlay")
subminer_log("info", "process", "Showing invisible overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
subminer_log("warn", "process", "Show invisible command failed")
show_osd("Show invisible failed")
return
end
state.invisible_overlay_visible = true
show_osd("Invisible overlay: visible")
end
local function hide_invisible_overlay()
if not ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
local args = build_command_args("hide-invisible-overlay")
subminer_log("info", "process", "Hiding invisible overlay: " .. table.concat(args, " "))
local result = mp.command_native({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if result and result.status ~= 0 then
subminer_log("warn", "process", "Hide invisible command failed")
show_osd("Hide invisible failed")
return
end
state.invisible_overlay_visible = false
show_osd("Invisible overlay: hidden")
end
local function open_options() local function open_options()
if not state.binary_available then if not state.binary_available then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
@@ -1768,7 +1691,6 @@ local function show_menu()
"Start overlay", "Start overlay",
"Stop overlay", "Stop overlay",
"Toggle overlay", "Toggle overlay",
"Toggle invisible overlay",
"Open options", "Open options",
"Restart overlay", "Restart overlay",
"Check status", "Check status",
@@ -1778,7 +1700,6 @@ local function show_menu()
start_overlay, start_overlay,
stop_overlay, stop_overlay,
toggle_overlay, toggle_overlay,
toggle_invisible_overlay,
open_options, open_options,
restart_overlay, restart_overlay,
check_status, check_status,
@@ -1856,28 +1777,16 @@ check_status = function()
end end
local function on_file_loaded() local function on_file_loaded()
if not is_subminer_app_running() then
clear_aniskip_state()
subminer_log("debug", "lifecycle", "Skipping file load hooks: SubMiner app not running")
return true
end
clear_aniskip_state() clear_aniskip_state()
fetch_aniskip_for_current_media() fetch_aniskip_for_current_media()
state.binary_path = find_binary() refresh_subminer_runtime_state()
if state.binary_path then if not state.binary_available then
state.binary_available = true return
subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")") end
local should_auto_start = coerce_bool(opts.auto_start, false)
if should_auto_start then local should_auto_start = coerce_bool(opts.auto_start, false)
start_overlay() if should_auto_start then
end start_overlay()
else
state.binary_available = false
subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled")
if opts.binary_path ~= "" then
subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist")
end
end end
end end
@@ -1895,9 +1804,6 @@ local function register_keybindings()
mp.add_key_binding("y-s", "subminer-start", start_overlay) mp.add_key_binding("y-s", "subminer-start", start_overlay)
mp.add_key_binding("y-S", "subminer-stop", stop_overlay) mp.add_key_binding("y-S", "subminer-stop", stop_overlay)
mp.add_key_binding("y-t", "subminer-toggle", toggle_overlay) mp.add_key_binding("y-t", "subminer-toggle", toggle_overlay)
mp.add_key_binding("y-i", "subminer-toggle-invisible", toggle_invisible_overlay)
mp.add_key_binding("y-I", "subminer-show-invisible", show_invisible_overlay)
mp.add_key_binding("y-u", "subminer-hide-invisible", hide_invisible_overlay)
mp.add_key_binding("y-y", "subminer-menu", show_menu) mp.add_key_binding("y-y", "subminer-menu", show_menu)
mp.add_key_binding("y-o", "subminer-options", open_options) mp.add_key_binding("y-o", "subminer-options", open_options)
mp.add_key_binding("y-r", "subminer-restart", restart_overlay) mp.add_key_binding("y-r", "subminer-restart", restart_overlay)
@@ -1914,9 +1820,6 @@ local function register_script_messages()
mp.register_script_message("subminer-start", start_overlay_from_script_message) mp.register_script_message("subminer-start", start_overlay_from_script_message)
mp.register_script_message("subminer-stop", stop_overlay) mp.register_script_message("subminer-stop", stop_overlay)
mp.register_script_message("subminer-toggle", toggle_overlay) mp.register_script_message("subminer-toggle", toggle_overlay)
mp.register_script_message("subminer-toggle-invisible", toggle_invisible_overlay)
mp.register_script_message("subminer-show-invisible", show_invisible_overlay)
mp.register_script_message("subminer-hide-invisible", hide_invisible_overlay)
mp.register_script_message("subminer-menu", show_menu) mp.register_script_message("subminer-menu", show_menu)
mp.register_script_message("subminer-options", open_options) mp.register_script_message("subminer-options", open_options)
mp.register_script_message("subminer-restart", restart_overlay) mp.register_script_message("subminer-restart", restart_overlay)

63
scripts/dev-watch.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
electron_args=("$@")
if [[ ${#electron_args[@]} -eq 0 ]]; then
electron_args=(--start --dev)
fi
if ! command -v bun >/dev/null 2>&1; then
echo "[ERROR] bun not found in PATH" >&2
exit 1
fi
TS_WATCH_PID=""
RENDER_WATCH_PID=""
cleanup() {
local pids=("$TS_WATCH_PID" "$RENDER_WATCH_PID")
for pid in "${pids[@]}"; do
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
sync_renderer_assets() {
mkdir -p dist/renderer
cp src/renderer/index.html src/renderer/style.css dist/renderer/
mkdir -p dist/renderer/fonts
cp -R src/renderer/fonts/. dist/renderer/fonts/
}
echo "[INFO] Syncing renderer static assets"
sync_renderer_assets
echo "[INFO] Running initial compile"
bun run tsc
bun run build:renderer
echo "[INFO] Starting TypeScript watch"
bun run tsc --watch --preserveWatchOutput &
TS_WATCH_PID=$!
echo "[INFO] Starting renderer watch"
bunx esbuild src/renderer/renderer.ts \
--bundle \
--platform=browser \
--format=esm \
--target=es2022 \
--outfile=dist/renderer/renderer.js \
--sourcemap \
--watch &
RENDER_WATCH_PID=$!
echo "[INFO] Launching Electron with args: ${electron_args[*]}"
bun run electron . "${electron_args[@]}"

8
scripts/subminer-dev.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
exec bun run electron . "$@"

View File

@@ -4,14 +4,11 @@ export interface CliArgs {
stop: boolean; stop: boolean;
toggle: boolean; toggle: boolean;
toggleVisibleOverlay: boolean; toggleVisibleOverlay: boolean;
toggleInvisibleOverlay: boolean;
settings: boolean; settings: boolean;
show: boolean; show: boolean;
hide: boolean; hide: boolean;
showVisibleOverlay: boolean; showVisibleOverlay: boolean;
hideVisibleOverlay: boolean; hideVisibleOverlay: boolean;
showInvisibleOverlay: boolean;
hideInvisibleOverlay: boolean;
copySubtitle: boolean; copySubtitle: boolean;
copySubtitleMultiple: boolean; copySubtitleMultiple: boolean;
mineSentence: boolean; mineSentence: boolean;
@@ -67,14 +64,11 @@ export function parseArgs(argv: string[]): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false, settings: false,
show: false, show: false,
hide: false, hide: false,
showVisibleOverlay: false, showVisibleOverlay: false,
hideVisibleOverlay: false, hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false, copySubtitle: false,
copySubtitleMultiple: false, copySubtitleMultiple: false,
mineSentence: false, mineSentence: false,
@@ -122,14 +116,11 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--stop') args.stop = true; else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true; else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-invisible-overlay') args.toggleInvisibleOverlay = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true; else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--show') args.show = true; else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true; else if (arg === '--hide') args.hide = true;
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true; else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true; else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true;
else if (arg === '--show-invisible-overlay') args.showInvisibleOverlay = true;
else if (arg === '--hide-invisible-overlay') args.hideInvisibleOverlay = true;
else if (arg === '--copy-subtitle') args.copySubtitle = true; else if (arg === '--copy-subtitle') args.copySubtitle = true;
else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true; else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true;
else if (arg === '--mine-sentence') args.mineSentence = true; else if (arg === '--mine-sentence') args.mineSentence = true;
@@ -263,14 +254,11 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.stop || args.stop ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.settings || args.settings ||
args.show || args.show ||
args.hide || args.hide ||
args.showVisibleOverlay || args.showVisibleOverlay ||
args.hideVisibleOverlay || args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle || args.copySubtitle ||
args.copySubtitleMultiple || args.copySubtitleMultiple ||
args.mineSentence || args.mineSentence ||
@@ -307,7 +295,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.start || args.start ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.copySubtitle || args.copySubtitle ||
args.copySubtitleMultiple || args.copySubtitleMultiple ||
args.mineSentence || args.mineSentence ||
@@ -331,13 +318,10 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return ( return (
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.show || args.show ||
args.hide || args.hide ||
args.showVisibleOverlay || args.showVisibleOverlay ||
args.hideVisibleOverlay || args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle || args.copySubtitle ||
args.copySubtitleMultiple || args.copySubtitleMultiple ||
args.mineSentence || args.mineSentence ||

View File

@@ -17,11 +17,8 @@ ${B}Session${R}
${B}Overlay${R} ${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay --toggle-visible-overlay Toggle subtitle overlay
--toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R}
--show-visible-overlay Show subtitle overlay --show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay --hide-visible-overlay Hide subtitle overlay
--show-invisible-overlay Show interactive overlay
--hide-invisible-overlay Hide interactive overlay
--settings Open Yomitan settings window --settings Open Yomitan settings window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect --auto-start-overlay Auto-hide mpv subs, show overlay on connect

View File

@@ -27,7 +27,22 @@ test('loads defaults when config is missing', () => {
assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6'); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(
config.subtitleStyle.fontFamily,
'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
);
assert.equal(config.subtitleStyle.fontWeight, '600');
assert.equal(config.subtitleStyle.lineHeight, 1.35);
assert.equal(config.subtitleStyle.letterSpacing, '-0.01em');
assert.equal(config.subtitleStyle.wordSpacing, 0);
assert.equal(config.subtitleStyle.fontKerning, 'normal');
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Manrope, Inter');
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.immersionTracking.enabled, true); assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, ''); assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25); assert.equal(config.immersionTracking.batchSize, 25);
@@ -136,6 +151,44 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
); );
}); });
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": "#363a4fd6"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": true
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
);
});
test('parses anilist.enabled and warns for invalid value', () => { test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -597,19 +650,15 @@ test('warns and ignores unknown top-level config keys', () => {
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag')); assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
}); });
test('parses invisible overlay config and new global shortcuts', () => { test('parses global shortcuts and startup visibility flags', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+U", "toggleVisibleOverlayGlobal": "Alt+Shift+U",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
"openJimaku": "Ctrl+Alt+J" "openJimaku": "Ctrl+Alt+J"
}, },
"invisibleOverlay": {
"startupVisibility": "hidden"
},
"bind_visible_overlay_to_mpv_sub_visibility": false, "bind_visible_overlay_to_mpv_sub_visibility": false,
"youtubeSubgen": { "youtubeSubgen": {
"primarySubLanguages": ["ja", "jpn", "jp"] "primarySubLanguages": ["ja", "jpn", "jp"]
@@ -621,9 +670,7 @@ test('parses invisible overlay config and new global shortcuts', () => {
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U'); assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, 'Alt+Shift+I');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J'); assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.equal(config.invisibleOverlay.startupVisibility, 'hidden');
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false); assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']); assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
}); });

View File

@@ -29,7 +29,6 @@ const {
subsync, subsync,
auto_start_overlay, auto_start_overlay,
bind_visible_overlay_to_mpv_sub_visibility, bind_visible_overlay_to_mpv_sub_visibility,
invisibleOverlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } = const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG; INTEGRATIONS_DEFAULT_CONFIG;
@@ -54,7 +53,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jellyfin, jellyfin,
discordPresence, discordPresence,
youtubeSubgen, youtubeSubgen,
invisibleOverlay,
immersionTracking, immersionTracking,
}; };

View File

@@ -12,7 +12,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'subsync' | 'subsync'
| 'auto_start_overlay' | 'auto_start_overlay'
| 'bind_visible_overlay_to_mpv_sub_visibility' | 'bind_visible_overlay_to_mpv_sub_visibility'
| 'invisibleOverlay'
> = { > = {
subtitlePosition: { yPercent: 10 }, subtitlePosition: { yPercent: 10 },
keybindings: [], keybindings: [],
@@ -28,7 +27,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
}, },
shortcuts: { shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O', toggleVisibleOverlayGlobal: 'Alt+Shift+O',
toggleInvisibleOverlayGlobal: 'Alt+Shift+I',
copySubtitle: 'CommandOrControl+C', copySubtitle: 'CommandOrControl+C',
copySubtitleMultiple: 'CommandOrControl+Shift+C', copySubtitleMultiple: 'CommandOrControl+Shift+C',
updateLastCardFromClipboard: 'CommandOrControl+V', updateLastCardFromClipboard: 'CommandOrControl+V',
@@ -55,7 +53,4 @@ export const CORE_DEFAULT_CONFIG: Pick<
}, },
auto_start_overlay: false, auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true, bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default',
},
}; };

View File

@@ -4,14 +4,21 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: { subtitleStyle: {
enableJlpt: false, enableJlpt: false,
preserveLineBreaks: false, preserveLineBreaks: false,
hoverTokenColor: '#c6a0f6', hoverTokenColor: '#f4dbd6',
fontFamily: hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
'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', fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35, fontSize: 35,
fontColor: '#cad3f5', fontColor: '#cad3f5',
fontWeight: 'normal', fontWeight: '600',
lineHeight: 1.35,
letterSpacing: '-0.01em',
wordSpacing: 0,
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 3px 10px rgba(0,0,0,0.69)',
fontStyle: 'normal', fontStyle: 'normal',
backgroundColor: 'rgb(30, 32, 48, 0.88)', backgroundColor: 'rgb(30, 32, 48, 0.88)',
backdropFilter: 'blur(6px)',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
jlptColors: { jlptColors: {
@@ -30,13 +37,19 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'], bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
}, },
secondary: { secondary: {
fontFamily: 'Manrope, Inter',
fontSize: 24, fontSize: 24,
fontColor: '#ffffff', fontColor: '#cad3f5',
lineHeight: 1.35,
letterSpacing: '-0.01em',
wordSpacing: 0,
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 3px 10px rgba(0,0,0,0.69)',
backgroundColor: 'transparent', backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
fontWeight: 'normal', fontWeight: 'normal',
fontStyle: 'normal', fontStyle: 'normal',
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',
}, },
}, },
}; };

View File

@@ -43,7 +43,7 @@ export function buildCoreConfigOptionRegistry(
kind: 'boolean', kind: 'boolean',
defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility, defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility,
description: description:
'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).', 'Link visible overlay toggles to MPV primary subtitle visibility.',
}, },
]; ];
} }

View File

@@ -27,6 +27,12 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor, defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
description: 'Hex color used for hovered subtitle token highlight in mpv.', description: 'Hex color used for hovered subtitle token highlight in mpv.',
}, },
{
path: 'subtitleStyle.hoverTokenBackgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor,
description: 'CSS color used for hovered subtitle token background highlight in mpv.',
},
{ {
path: 'subtitleStyle.frequencyDictionary.enabled', path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean', kind: 'boolean',

View File

@@ -40,15 +40,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'], notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts', key: 'shortcuts',
}, },
{
title: 'Invisible Overlay',
description: ['Startup behavior for the invisible interactive subtitle mining layer.'],
notes: [
'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.',
],
key: 'invisibleOverlay',
},
{ {
title: 'Keybindings (MPV Commands)', title: 'Keybindings (MPV Commands)',
description: [ description: [

View File

@@ -77,7 +77,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (isObject(src.shortcuts)) { if (isObject(src.shortcuts)) {
const shortcutKeys = [ const shortcutKeys = [
'toggleVisibleOverlayGlobal', 'toggleVisibleOverlayGlobal',
'toggleInvisibleOverlayGlobal',
'copySubtitle', 'copySubtitle',
'copySubtitleMultiple', 'copySubtitleMultiple',
'updateLastCardFromClipboard', 'updateLastCardFromClipboard',
@@ -113,24 +112,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
} }
} }
if (isObject(src.invisibleOverlay)) {
const startupVisibility = src.invisibleOverlay.startupVisibility;
if (
startupVisibility === 'platform-default' ||
startupVisibility === 'visible' ||
startupVisibility === 'hidden'
) {
resolved.invisibleOverlay.startupVisibility = startupVisibility;
} else if (startupVisibility !== undefined) {
warn(
'invisibleOverlay.startupVisibility',
startupVisibility,
resolved.invisibleOverlay.startupVisibility,
'Expected platform-default, visible, or hidden.',
);
}
}
if (isObject(src.secondarySub)) { if (isObject(src.secondarySub)) {
if (Array.isArray(src.secondarySub.secondarySubLanguages)) { if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter(

View File

@@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor;
resolved.subtitleStyle = { resolved.subtitleStyle = {
...resolved.subtitleStyle, ...resolved.subtitleStyle,
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']), ...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
@@ -154,6 +156,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
const hoverTokenBackgroundColor = asString(
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
);
if (hoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
} else if (
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
undefined
) {
resolved.subtitleStyle.hoverTokenBackgroundColor = fallbackSubtitleStyleHoverTokenBackgroundColor;
warn(
'subtitleStyle.hoverTokenBackgroundColor',
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
resolved.subtitleStyle.hoverTokenBackgroundColor,
'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).',
);
}
const frequencyDictionary = isObject( const frequencyDictionary = isObject(
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
) )

View File

@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false, settings: false,
show: false, show: false,
hide: false, hide: false,
showVisibleOverlay: false, showVisibleOverlay: false,
hideVisibleOverlay: false, hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false, copySubtitle: false,
copySubtitleMultiple: false, copySubtitleMultiple: false,
mineSentence: false, mineSentence: false,
@@ -94,18 +91,12 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
toggleVisibleOverlay: () => { toggleVisibleOverlay: () => {
calls.push('toggleVisibleOverlay'); calls.push('toggleVisibleOverlay');
}, },
toggleInvisibleOverlay: () => {
calls.push('toggleInvisibleOverlay');
},
openYomitanSettingsDelayed: (delayMs) => { openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`); calls.push(`openYomitanSettingsDelayed:${delayMs}`);
}, },
setVisibleOverlayVisible: (visible) => { setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`); calls.push(`setVisibleOverlayVisible:${visible}`);
}, },
setInvisibleOverlayVisible: (visible) => {
calls.push(`setInvisibleOverlayVisible:${visible}`);
},
copyCurrentSubtitle: () => { copyCurrentSubtitle: () => {
calls.push('copyCurrentSubtitle'); calls.push('copyCurrentSubtitle');
}, },
@@ -339,10 +330,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>; args: Partial<CliArgs>;
expected: string; expected: string;
}> = [ }> = [
{
args: { toggleInvisibleOverlay: true },
expected: 'toggleInvisibleOverlay',
},
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' }, { args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ {
args: { showVisibleOverlay: true }, args: { showVisibleOverlay: true },
@@ -352,14 +339,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: { hideVisibleOverlay: true }, args: { hideVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:false', expected: 'setVisibleOverlayVisible:false',
}, },
{
args: { showInvisibleOverlay: true },
expected: 'setInvisibleOverlayVisible:true',
},
{
args: { hideInvisibleOverlay: true },
expected: 'setInvisibleOverlayVisible:false',
},
{ args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' }, { args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
{ {
args: { copySubtitleMultiple: true }, args: { copySubtitleMultiple: true },

View File

@@ -16,10 +16,8 @@ export interface CliCommandServiceDeps {
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void; openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>; mineSentenceCard: () => Promise<void>;
@@ -93,9 +91,7 @@ interface OverlayCliRuntime {
isInitialized: () => boolean; isInitialized: () => boolean;
initialize: () => void; initialize: () => void;
toggleVisible: () => void; toggleVisible: () => void;
toggleInvisible: () => void;
setVisible: (visible: boolean) => void; setVisible: (visible: boolean) => void;
setInvisible: (visible: boolean) => void;
} }
interface MiningCliRuntime { interface MiningCliRuntime {
@@ -180,14 +176,12 @@ export function createCliCommandDepsRuntime(
isOverlayRuntimeInitialized: options.overlay.isInitialized, isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize, initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible, toggleVisibleOverlay: options.overlay.toggleVisible,
toggleInvisibleOverlay: options.overlay.toggleInvisible,
openYomitanSettingsDelayed: (delayMs) => { openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => { options.schedule(() => {
options.ui.openYomitanSettings(); options.ui.openYomitanSettings();
}, delayMs); }, delayMs);
}, },
setVisibleOverlayVisible: options.overlay.setVisible, setVisibleOverlayVisible: options.overlay.setVisible,
setInvisibleOverlayVisible: options.overlay.setInvisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle, copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy, startPendingMultiCopy: options.mining.startPendingMultiCopy,
mineSentenceCard: options.mining.mineSentenceCard, mineSentenceCard: options.mining.mineSentenceCard,
@@ -242,14 +236,11 @@ export function handleCliCommand(
args.stop || args.stop ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.settings || args.settings ||
args.show || args.show ||
args.hide || args.hide ||
args.showVisibleOverlay || args.showVisibleOverlay ||
args.hideVisibleOverlay || args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle || args.copySubtitle ||
args.copySubtitleMultiple || args.copySubtitleMultiple ||
args.mineSentence || args.mineSentence ||
@@ -286,10 +277,7 @@ export function handleCliCommand(
} }
const shouldStart = const shouldStart =
args.start || args.start || args.toggle || args.toggleVisibleOverlay;
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args); const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start; const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
@@ -325,18 +313,12 @@ export function handleCliCommand(
if (args.toggle || args.toggleVisibleOverlay) { if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay(); deps.toggleVisibleOverlay();
} else if (args.toggleInvisibleOverlay) {
deps.toggleInvisibleOverlay();
} else if (args.settings) { } else if (args.settings) {
deps.openYomitanSettingsDelayed(1000); deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) { } else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true); deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) { } else if (args.hide || args.hideVisibleOverlay) {
deps.setVisibleOverlayVisible(false); deps.setVisibleOverlayVisible(false);
} else if (args.showInvisibleOverlay) {
deps.setInvisibleOverlayVisible(true);
} else if (args.hideInvisibleOverlay) {
deps.setInvisibleOverlayVisible(false);
} else if (args.copySubtitle) { } else if (args.copySubtitle) {
deps.copyCurrentSubtitle(); deps.copyCurrentSubtitle();
} else if (args.copySubtitleMultiple) { } else if (args.copySubtitleMultiple) {

View File

@@ -19,11 +19,9 @@ test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore
}, },
}), }),
getVisibleOverlayVisible: () => visible, getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (next) => { setVisibleOverlayVisible: (next) => {
visible = next; visible = next;
}, },
setInvisibleOverlayVisible: () => {},
getResolver: () => null, getResolver: () => null,
setResolver: () => {}, setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => restore, getRestoreVisibleOverlayOnModalClose: () => restore,
@@ -44,9 +42,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null, getMainWindow: () => null,
getVisibleOverlayVisible: () => false, getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {}, setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver, getResolver: () => resolver,
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => { setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = next; resolver = next;
@@ -87,12 +83,10 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null, getMainWindow: () => null,
getVisibleOverlayVisible: () => visible, getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (nextVisible) => { setVisibleOverlayVisible: (nextVisible) => {
visible = nextVisible; visible = nextVisible;
visibilityTransitions.push(nextVisible); visibilityTransitions.push(nextVisible);
}, },
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null, getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => { setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = nextResolver; resolver = nextResolver;

View File

@@ -11,9 +11,7 @@ interface WindowLike {
export interface FieldGroupingOverlayRuntimeOptions<T extends string> { export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>; getRestoreVisibleOverlayOnModalClose: () => Set<T>;
@@ -65,9 +63,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
) => Promise<KikuFieldGroupingChoice>) => { ) => Promise<KikuFieldGroupingChoice>) => {
return createFieldGroupingCallbackRuntime({ return createFieldGroupingCallbackRuntime({
getVisibleOverlayVisible: options.getVisibleOverlayVisible, getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible, setVisibleOverlayVisible: options.setVisibleOverlayVisible,
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
getResolver: options.getResolver, getResolver: options.getResolver,
setResolver: options.setResolver, setResolver: options.setResolver,
sendToVisibleOverlay, sendToVisibleOverlay,

View File

@@ -2,9 +2,7 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../typ
export function createFieldGroupingCallback(options: { export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean; sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
@@ -22,7 +20,6 @@ export function createFieldGroupingCallback(options: {
} }
const previousVisibleOverlay = options.getVisibleOverlayVisible(); const previousVisibleOverlay = options.getVisibleOverlayVisible();
const previousInvisibleOverlay = options.getInvisibleOverlayVisible();
let settled = false; let settled = false;
const finish = (choice: KikuFieldGroupingChoice): void => { const finish = (choice: KikuFieldGroupingChoice): void => {
@@ -36,9 +33,6 @@ export function createFieldGroupingCallback(options: {
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) { if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false); options.setVisibleOverlayVisible(false);
} }
if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) {
options.setInvisibleOverlayVisible(previousInvisibleOverlay);
}
}; };
options.setResolver(finish); options.setResolver(finish);

View File

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

View File

@@ -36,10 +36,8 @@ function createFakeIpcRegistrar(): {
test('createIpcDepsRuntime wires AniList handlers', async () => { test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createIpcDepsRuntime({ const deps = createIpcDepsRuntime({
getInvisibleWindow: () => null,
getMainWindow: () => null, getMainWindow: () => null,
getVisibleOverlayVisibility: () => false, getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {}, onOverlayModalClosed: () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
quitApp: () => {}, quitApp: () => {},
@@ -47,7 +45,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
@@ -64,7 +61,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
setRuntimeOption: () => ({ ok: true }), setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {}, reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({ tokenStatus: 'resolved' }), getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
clearAnilistToken: () => { clearAnilistToken: () => {
calls.push('clearAnilistToken'); calls.push('clearAnilistToken');
@@ -101,20 +97,15 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
const cycles: Array<{ id: string; direction: 1 | -1 }> = []; const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
registerIpcHandlers( registerIpcHandlers(
{ {
getInvisibleWindow: () => null,
isVisibleOverlayVisible: () => false,
setInvisibleIgnoreMouseEvents: () => {},
onOverlayModalClosed: () => {}, onOverlayModalClosed: () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
quitApp: () => {}, quitApp: () => {},
toggleDevTools: () => {}, toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false, getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
getInvisibleOverlayVisibility: () => false,
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
@@ -138,7 +129,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
return { ok: true }; return { ok: true };
}, },
reportOverlayContentBounds: () => {}, reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}), getAnilistStatus: () => ({}),
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetup: () => {}, openAnilistSetup: () => {},
@@ -176,25 +166,24 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = []; const saves: unknown[] = [];
const modals: unknown[] = []; const closedModals: unknown[] = [];
const openedModals: unknown[] = [];
registerIpcHandlers( registerIpcHandlers(
{ {
getInvisibleWindow: () => null,
isVisibleOverlayVisible: () => false,
setInvisibleIgnoreMouseEvents: () => {},
onOverlayModalClosed: (modal) => { onOverlayModalClosed: (modal) => {
modals.push(modal); closedModals.push(modal);
},
onOverlayModalOpened: (modal) => {
openedModals.push(modal);
}, },
openYomitanSettings: () => {}, openYomitanSettings: () => {},
quitApp: () => {}, quitApp: () => {},
toggleDevTools: () => {}, toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false, getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
getInvisibleOverlayVisibility: () => false,
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,
getSubtitleStyle: () => null, getSubtitleStyle: () => null,
saveSubtitlePosition: (position) => { saveSubtitlePosition: (position) => {
@@ -214,7 +203,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
setRuntimeOption: () => ({ ok: true }), setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {}, reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}), getAnilistStatus: () => ({}),
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetup: () => {}, openAnilistSetup: () => {},
@@ -228,11 +216,16 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' }); handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' });
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 }); handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
assert.deepEqual(saves, [ assert.deepEqual(saves, [
{ yPercent: 42, invisibleOffsetXPx: undefined, invisibleOffsetYPx: undefined }, { yPercent: 42 },
]); ]);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
assert.deepEqual(modals, ['subsync', 'kiku']); assert.deepEqual(closedModals, ['subsync', 'kiku']);
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
}); });

View File

@@ -19,20 +19,16 @@ import {
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
export interface IpcServiceDeps { export interface IpcServiceDeps {
getInvisibleWindow: () => WindowLike | null;
isVisibleOverlayVisible: () => boolean;
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void; saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -54,7 +50,6 @@ export interface IpcServiceDeps {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void; reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown; getAnilistStatus: () => unknown;
clearAnilistToken: () => void; clearAnilistToken: () => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
@@ -91,18 +86,16 @@ interface IpcMainRegistrar {
} }
export interface IpcDepsRuntimeOptions { export interface IpcDepsRuntimeOptions {
getInvisibleWindow: () => WindowLike | null;
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
getInvisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void; saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -119,7 +112,6 @@ export interface IpcDepsRuntimeOptions {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void; reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown; getAnilistStatus: () => unknown;
clearAnilistToken: () => void; clearAnilistToken: () => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
@@ -130,14 +122,8 @@ export interface IpcDepsRuntimeOptions {
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps { export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
return { return {
getInvisibleWindow: () => options.getInvisibleWindow(),
isVisibleOverlayVisible: options.getVisibleOverlayVisibility,
setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => {
const invisibleWindow = options.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions);
},
onOverlayModalClosed: options.onOverlayModalClosed, onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
openYomitanSettings: options.openYomitanSettings, openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp, quitApp: options.quitApp,
toggleDevTools: () => { toggleDevTools: () => {
@@ -147,11 +133,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
}, },
getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, getVisibleOverlayVisibility: options.getVisibleOverlayVisibility,
toggleVisibleOverlay: options.toggleVisibleOverlay, toggleVisibleOverlay: options.toggleVisibleOverlay,
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss, getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
getSubtitlePosition: options.getSubtitlePosition, getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle, getSubtitleStyle: options.getSubtitleStyle,
saveSubtitlePosition: options.saveSubtitlePosition, saveSubtitlePosition: options.saveSubtitlePosition,
@@ -182,7 +166,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
setRuntimeOption: options.setRuntimeOption, setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption, cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds, reportOverlayContentBounds: options.reportOverlayContentBounds,
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
getAnilistStatus: options.getAnilistStatus, getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken, clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup, openAnilistSetup: options.openAnilistSetup,
@@ -200,17 +183,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
const parsedOptions = parseOptionalForwardingOptions(options); const parsedOptions = parseOptionalForwardingOptions(options);
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender); const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
if (senderWindow && !senderWindow.isDestroyed()) { if (senderWindow && !senderWindow.isDestroyed()) {
const invisibleWindow = deps.getInvisibleWindow(); senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
if (
senderWindow === invisibleWindow &&
deps.isVisibleOverlayVisible() &&
invisibleWindow &&
!invisibleWindow.isDestroyed()
) {
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
} else {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
} }
}, },
); );
@@ -220,6 +193,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (!parsedModal) return; if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal); deps.onOverlayModalClosed(parsedModal);
}); });
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
if (!deps.onOverlayModalOpened) return;
deps.onOverlayModalOpened(parsedModal);
});
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => { ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings(); deps.openYomitanSettings();
@@ -233,10 +212,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.toggleDevTools(); deps.toggleDevTools();
}); });
ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => {
return deps.getVisibleOverlayVisibility();
});
ipc.on(IPC_CHANNELS.command.toggleOverlay, () => { ipc.on(IPC_CHANNELS.command.toggleOverlay, () => {
deps.toggleVisibleOverlay(); deps.toggleVisibleOverlay();
}); });
@@ -245,10 +220,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getVisibleOverlayVisibility(); return deps.getVisibleOverlayVisibility();
}); });
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
return deps.getInvisibleOverlayVisibility();
});
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => { ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
return await deps.tokenizeCurrentSubtitle(); return await deps.tokenizeCurrentSubtitle();
}); });
@@ -261,10 +232,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getCurrentSubtitleAss(); return deps.getCurrentSubtitleAss();
}); });
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
return deps.getMpvSubtitleRenderMetrics();
});
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => { ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
return deps.getSubtitlePosition(); return deps.getSubtitlePosition();
}); });
@@ -358,17 +325,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.reportOverlayContentBounds(payload); deps.reportOverlayContentBounds(payload);
}); });
ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => {
if (tokenIndex === null) {
deps.reportHoveredSubtitleToken(null);
return;
}
if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) {
return;
}
deps.reportHoveredSubtitleToken(tokenIndex as number);
});
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => { ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
return deps.getAnilistStatus(); return deps.getAnilistStatus();
}); });

View File

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

View File

@@ -119,6 +119,36 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
}); });
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
const { deps, state } = createDeps({
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => true,
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-visibility', data: 'yes' },
deps,
);
assert.deepEqual(state.commands.pop(), {
command: ['set_property', 'sub-visibility', 'no'],
});
});
test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay binding is disabled', async () => {
const { deps, state } = createDeps({
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
isVisibleOverlayVisible: () => true,
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-visibility', data: 'yes' },
deps,
);
assert.equal(state.commands.length, 0);
});
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => { test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
const { deps, state } = createDeps(); const { deps, state } = createDeps();

View File

@@ -48,6 +48,7 @@ export interface MpvProtocolHandleMessageDeps {
}; };
getSubtitleMetrics: () => MpvSubtitleRenderMetrics; getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility?: () => boolean;
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
emitSubtitleAssChange: (payload: { text: string }) => void; emitSubtitleAssChange: (payload: { text: string }) => void;
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
@@ -216,6 +217,14 @@ export async function dispatchMpvProtocolMessage(
deps.emitSubtitleMetricsChange({ deps.emitSubtitleMetricsChange({
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow), subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow),
}); });
} else if (msg.name === 'sub-visibility') {
if (
deps.isVisibleOverlayVisible() &&
asBoolean(msg.data, false) &&
(deps.shouldBindVisibleOverlayToMpvSubVisibility?.() ?? true)
) {
deps.sendCommand({ command: ['set_property', 'sub-visibility', 'no'] });
}
} else if (msg.name === 'sub-use-margins') { } else if (msg.name === 'sub-use-margins') {
deps.emitSubtitleMetricsChange({ deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins), subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),

View File

@@ -306,6 +306,32 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
assert.equal(hasPathRequest, true); assert.equal(hasPathRequest, true);
}); });
test('MpvIpcClient connect does not force primary subtitle visibility from binding path', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isVisibleOverlayVisible: () => true,
}),
);
(client as any).send = (command: unknown) => {
commands.push(command);
return true;
};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const hasPrimaryVisibilityMutation = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === 'set_property' &&
(command as { command: unknown[] }).command[1] === 'sub-visibility',
);
assert.equal(hasPrimaryVisibilityMutation, false);
});
test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => { test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
const commands: unknown[] = []; const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());

View File

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

View File

@@ -33,13 +33,51 @@ test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden o
assert.deepEqual(sent, [['runtime-options:open']]); assert.deepEqual(sent, [['runtime-options:open']]);
}); });
test('sendToVisibleOverlayRuntime waits for overlay page before sending open command', () => {
const sent: unknown[][] = [];
const restoreSet = new Set<'runtime-options' | 'subsync'>();
let loading = true;
let currentURL = '';
const finishCallbacks: Array<() => void> = [];
const ok = sendToVisibleOverlayRuntime({
mainWindow: {
isDestroyed: () => false,
webContents: {
isLoading: () => loading,
getURL: () => currentURL,
send: (...args: unknown[]) => {
sent.push(args);
},
once: (_event: string, callback: () => void) => {
finishCallbacks.push(callback);
},
},
} as unknown as Electron.BrowserWindow,
visibleOverlayVisible: false,
setVisibleOverlayVisible: () => {},
channel: 'runtime-options:open',
restoreOnModalClose: 'runtime-options',
restoreVisibleOverlayOnModalClose: restoreSet,
});
assert.equal(ok, true);
assert.deepEqual(sent, []);
assert.equal(restoreSet.has('runtime-options'), true);
loading = false;
currentURL = 'file:///overlay/index.html?layer=visible';
assert.ok(finishCallbacks[0]);
finishCallbacks[0]!();
assert.deepEqual(sent, [['runtime-options:open']]);
});
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => { test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({ const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
getVisibleOverlayVisible: () => false, getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {}, setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver, getResolver: () => resolver,
setResolver: (next) => { setResolver: (next) => {
resolver = next; resolver = next;

View File

@@ -13,6 +13,11 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
}): boolean { }): boolean {
if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; if (!options.mainWindow || options.mainWindow.isDestroyed()) return false;
const wasVisible = options.visibleOverlayVisible; const wasVisible = options.visibleOverlayVisible;
const webContents = options.mainWindow.webContents as Electron.WebContents & {
isLoading?: () => boolean;
getURL?: () => string;
once?: (event: 'did-finish-load', listener: () => void) => void;
};
if (!options.visibleOverlayVisible) { if (!options.visibleOverlayVisible) {
options.setVisibleOverlayVisible(true); options.setVisibleOverlayVisible(true);
} }
@@ -21,32 +26,40 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
} }
const sendNow = (): void => { const sendNow = (): void => {
if (options.payload === undefined) { if (options.payload === undefined) {
options.mainWindow!.webContents.send(options.channel); webContents.send(options.channel);
} else { } else {
options.mainWindow!.webContents.send(options.channel, options.payload); webContents.send(options.channel, options.payload);
} }
}; };
if (options.mainWindow.webContents.isLoading()) {
options.mainWindow.webContents.once('did-finish-load', () => { const isLoading = typeof webContents.isLoading === 'function' ? webContents.isLoading() : false;
if ( const currentURL = typeof webContents.getURL === 'function' ? webContents.getURL() : '';
options.mainWindow && const isReady =
!options.mainWindow.isDestroyed() && !isLoading &&
!options.mainWindow.webContents.isLoading() currentURL !== '' &&
) { currentURL !== 'about:blank';
if (!isReady) {
if (typeof webContents.once !== 'function') {
sendNow();
return true;
}
webContents.once('did-finish-load', () => {
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (typeof webContents.isLoading !== 'function' || !webContents.isLoading()) {
sendNow(); sendNow();
} }
}); });
return true; return true;
} }
sendNow(); sendNow();
return true; return true;
} }
export function createFieldGroupingCallbackRuntime<T extends string>(options: { export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendToVisibleOverlay: ( sendToVisibleOverlay: (
@@ -57,9 +70,7 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> { }): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({ return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible, getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible, setVisibleOverlayVisible: options.setVisibleOverlayVisible,
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
getResolver: options.getResolver, getResolver: options.getResolver,
setResolver: options.setResolver, setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) => sendRequestToVisibleOverlay: (data) =>

View File

@@ -28,7 +28,7 @@ test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', (
test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => { test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
const measurement = sanitizeOverlayContentMeasurement( const measurement = sanitizeOverlayContentMeasurement(
{ {
layer: 'invisible', layer: 'visible',
measuredAtMs: 100, measuredAtMs: 100,
viewport: { width: 0, height: 1080 }, viewport: { width: 0, height: 1080 },
contentRect: { x: 0, y: 0, width: 100, height: 20 }, contentRect: { x: 0, y: 0, width: 100, height: 20 },
@@ -39,7 +39,7 @@ test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
assert.equal(measurement, null); assert.equal(measurement, null);
}); });
test('overlay measurement store keeps latest payload per layer', () => { test('overlay measurement store keeps latest payload for visible layer', () => {
const store = createOverlayContentMeasurementStore({ const store = createOverlayContentMeasurementStore({
now: () => 1000, now: () => 1000,
warn: () => { warn: () => {
@@ -53,17 +53,9 @@ test('overlay measurement store keeps latest payload per layer', () => {
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 }, contentRect: { x: 50, y: 60, width: 400, height: 80 },
}); });
const invisible = store.report({
layer: 'invisible',
measuredAtMs: 910,
viewport: { width: 1280, height: 720 },
contentRect: { x: 20, y: 30, width: 300, height: 40 },
});
assert.equal(visible?.layer, 'visible'); assert.equal(visible?.layer, 'visible');
assert.equal(invisible?.layer, 'invisible');
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400); assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
assert.equal(store.getLatestByLayer('invisible')?.contentRect?.height, 40);
}); });
test('overlay measurement store rate-limits invalid payload warnings', () => { test('overlay measurement store rate-limits invalid payload warnings', () => {

View File

@@ -28,7 +28,7 @@ export function sanitizeOverlayContentMeasurement(
} | null; } | null;
}; };
if (candidate.layer !== 'visible' && candidate.layer !== 'invisible') { if (candidate.layer !== 'visible') {
return null; return null;
} }
@@ -112,7 +112,6 @@ export function createOverlayContentMeasurementStore(options?: {
const warn = options?.warn ?? ((message: string) => logger.warn(message)); const warn = options?.warn ?? ((message: string) => logger.warn(message));
const latestByLayer: OverlayMeasurementStore = { const latestByLayer: OverlayMeasurementStore = {
visible: null, visible: null,
invisible: null,
}; };
let droppedInvalid = 0; let droppedInvalid = 0;

View File

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

View File

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

View File

@@ -10,16 +10,11 @@ import {
export function initializeOverlayRuntime(options: { export function initializeOverlayRuntime(options: {
backendOverride: string | null; backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void; createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void; registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[]; getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -38,12 +33,8 @@ export function initializeOverlayRuntime(options: {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
}): { }): void {
invisibleOverlayVisible: boolean;
} {
options.createMainWindow(); options.createMainWindow();
options.createInvisibleWindow();
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts(); options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath()); const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
@@ -51,17 +42,12 @@ export function initializeOverlayRuntime(options: {
if (windowTracker) { if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => { windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry); options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
}; };
windowTracker.onWindowFound = (geometry: WindowGeometry) => { windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry); options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
if (options.isVisibleOverlayVisible()) { if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility(); options.updateVisibleOverlayVisibility();
} }
if (options.isInvisibleOverlayVisible()) {
options.updateInvisibleOverlayVisibility();
}
}; };
windowTracker.onWindowLost = () => { windowTracker.onWindowLost = () => {
for (const window of options.getOverlayWindows()) { for (const window of options.getOverlayWindows()) {
@@ -101,7 +87,4 @@ export function initializeOverlayRuntime(options: {
} }
options.updateVisibleOverlayVisibility(); options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
return { invisibleOverlayVisible };
} }

View File

@@ -10,7 +10,6 @@ import {
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts { function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return { return {
toggleVisibleOverlayGlobal: null, toggleVisibleOverlayGlobal: null,
toggleInvisibleOverlayGlobal: null,
copySubtitle: null, copySubtitle: null,
copySubtitleMultiple: null, copySubtitleMultiple: null,
updateLastCardFromClipboard: null, updateLastCardFromClipboard: null,

View File

@@ -0,0 +1,265 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
type WindowTrackerStub = {
isTracking: () => boolean;
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
};
function createMainWindowRecorder() {
const calls: string[] = [];
const window = {
isDestroyed: () => false,
hide: () => {
calls.push('hide');
},
show: () => {
calls.push('show');
},
focus: () => {
calls.push('focus');
},
setIgnoreMouseEvents: () => {
calls.push('mouse-ignore');
},
};
return { window, calls };
}
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const osdMessages: string[] = [];
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
run();
run();
assert.equal(trackerWarning, true);
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
});
test('non-macOS keeps fallback visible overlay behavior when tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
showOverlayLoadingOsd: () => {
calls.push('osd');
},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.equal(trackerWarning, true);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('osd'));
});
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const osdMessages: string[] = [];
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
assert.equal(trackerWarning, true);
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('update-bounds'));
});
test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => {
const calls: string[] = [];
setVisibleOverlayVisible({
visible: true,
setVisibleOverlayVisibleState: (visible) => {
calls.push(`state:${visible}`);
},
updateVisibleOverlayVisibility: () => {
calls.push('update');
},
});
assert.deepEqual(calls, ['state:true', 'update']);
});
test('macOS loading OSD can show again after overlay is hidden and retried', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
let trackerWarning = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
calls.push(`warn:${shown ? 'yes' : 'no'}`);
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
updateVisibleOverlayVisibility({
visibleOverlayVisible: false,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
calls.push(`warn:${shown ? 'yes' : 'no'}`);
},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {},
} as never);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
calls.push(`warn:${shown ? 'yes' : 'no'}`);
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
});

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, screen } from 'electron'; import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers'; import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types'; import { WindowGeometry } from '../../types';
@@ -10,14 +10,19 @@ export function updateVisibleOverlayVisibility(args: {
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void; enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
isMacOSPlatform?: boolean;
showOverlayLoadingOsd?: (message: string) => void;
resolveFallbackBounds?: () => WindowGeometry;
}): void { }): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) { if (!args.mainWindow || args.mainWindow.isDestroyed()) {
return; return;
} }
if (!args.visibleOverlayVisible) { if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.mainWindow.hide(); args.mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
@@ -29,6 +34,8 @@ export function updateVisibleOverlayVisibility(args: {
if (geometry) { if (geometry) {
args.updateVisibleOverlayBounds(geometry); args.updateVisibleOverlayBounds(geometry);
} }
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow); args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show(); args.mainWindow.show();
args.mainWindow.focus(); args.mainWindow.focus();
@@ -38,7 +45,18 @@ export function updateVisibleOverlayVisibility(args: {
} }
if (!args.windowTracker) { if (!args.windowTracker) {
if (args.isMacOSPlatform) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
args.showOverlayLoadingOsd?.('Overlay loading...');
}
args.mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow); args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show(); args.mainWindow.show();
args.mainWindow.focus(); args.mainWindow.focus();
@@ -49,16 +67,23 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.trackerNotReadyWarningShown) { if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true); args.setTrackerNotReadyWarningShown(true);
if (args.isMacOSPlatform) {
args.showOverlayLoadingOsd?.('Overlay loading...');
}
} }
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint); if (args.isMacOSPlatform) {
const fallbackBounds = display.workArea; args.mainWindow.hide();
args.updateVisibleOverlayBounds({ args.syncOverlayShortcuts();
x: fallbackBounds.x, return;
y: fallbackBounds.y, }
width: fallbackBounds.width,
height: fallbackBounds.height, const fallbackBounds = args.resolveFallbackBounds?.();
}); if (!fallbackBounds) return;
args.updateVisibleOverlayBounds(fallbackBounds);
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow); args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show(); args.mainWindow.show();
args.mainWindow.focus(); args.mainWindow.focus();
@@ -66,111 +91,11 @@ export function updateVisibleOverlayVisibility(args: {
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
} }
export function updateInvisibleOverlayVisibility(args: {
invisibleWindow: BrowserWindow | null;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
windowTracker: BaseWindowTracker | null;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}): void {
if (!args.invisibleWindow || args.invisibleWindow.isDestroyed()) {
return;
}
if (args.visibleOverlayVisible) {
args.invisibleWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showInvisibleWithoutFocus = (): void => {
args.ensureOverlayWindowLevel(args.invisibleWindow!);
if (typeof args.invisibleWindow!.showInactive === 'function') {
args.invisibleWindow!.showInactive();
} else {
args.invisibleWindow!.show();
}
args.enforceOverlayLayerOrder();
};
if (!args.invisibleOverlayVisible) {
args.invisibleWindow.hide();
args.syncOverlayShortcuts();
return;
}
if (args.windowTracker && args.windowTracker.isTracking()) {
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateInvisibleOverlayBounds(geometry);
}
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
return;
}
if (!args.windowTracker) {
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
return;
}
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
args.updateInvisibleOverlayBounds({
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
});
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
}
export function syncInvisibleOverlayMousePassthrough(options: {
hasInvisibleWindow: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
}): void {
if (!options.hasInvisibleWindow()) return;
if (options.visibleOverlayVisible) {
options.setIgnoreMouseEvents(true, { forward: true });
} else if (options.invisibleOverlayVisible) {
options.setIgnoreMouseEvents(false);
}
}
export function setVisibleOverlayVisible(options: { export function setVisibleOverlayVisible(options: {
visible: boolean; visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void; setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}): void { }): void {
options.setVisibleOverlayVisibleState(options.visible); options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility(); options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) {
options.setMpvSubVisibility(!options.visible);
}
}
export function setInvisibleOverlayVisible(options: {
visible: boolean;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}): void {
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
} }

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime, isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig, shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility, shouldBindVisibleOverlayToMpvSubVisibility,
@@ -10,9 +9,6 @@ import {
const BASE_CONFIG = { const BASE_CONFIG = {
auto_start_overlay: false, auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true, bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default' as const,
},
ankiConnect: { ankiConnect: {
behavior: { behavior: {
autoUpdateNewCards: true, autoUpdateNewCards: true,
@@ -20,26 +16,7 @@ const BASE_CONFIG = {
}, },
}; };
test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => { test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start', () => {
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'visible' } },
'linux',
),
true,
);
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'hidden' } },
'darwin',
),
false,
);
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'linux'), false);
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'darwin'), true);
});
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visible startup', () => {
assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false); assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false);
assert.equal( assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({ shouldAutoInitializeOverlayRuntimeFromConfig({
@@ -48,13 +25,6 @@ test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visib
}), }),
true, true,
); );
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
...BASE_CONFIG,
invisibleOverlay: { startupVisibility: 'visible' },
}),
true,
);
}); });
test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => { test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => {

View File

@@ -5,14 +5,12 @@ const logger = createLogger('main:shortcut');
export interface GlobalShortcutConfig { export interface GlobalShortcutConfig {
toggleVisibleOverlayGlobal: string | null | undefined; toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
openJimaku?: string | null | undefined; openJimaku?: string | null | undefined;
} }
export interface RegisterGlobalShortcutsServiceOptions { export interface RegisterGlobalShortcutsServiceOptions {
shortcuts: GlobalShortcutConfig; shortcuts: GlobalShortcutConfig;
onToggleVisibleOverlay: () => void; onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void; onOpenYomitanSettings: () => void;
onOpenJimaku?: () => void; onOpenJimaku?: () => void;
isDev: boolean; isDev: boolean;
@@ -21,9 +19,7 @@ export interface RegisterGlobalShortcutsServiceOptions {
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void { export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal; const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
const invisibleShortcut = options.shortcuts.toggleInvisibleOverlayGlobal;
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase(); const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedInvisible = invisibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase(); const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
const normalizedSettings = 'alt+shift+y'; const normalizedSettings = 'alt+shift+y';
@@ -38,31 +34,10 @@ export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceO
} }
} }
if (invisibleShortcut && normalizedInvisible && normalizedInvisible !== normalizedVisible) {
const toggleInvisibleRegistered = globalShortcut.register(invisibleShortcut, () => {
options.onToggleInvisibleOverlay();
});
if (!toggleInvisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`,
);
}
} else if (
invisibleShortcut &&
normalizedInvisible &&
normalizedInvisible === normalizedVisible
) {
logger.warn(
'Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal',
);
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) { if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if ( if (
normalizedJimaku && normalizedJimaku &&
(normalizedJimaku === normalizedVisible || (normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
normalizedJimaku === normalizedInvisible ||
normalizedJimaku === normalizedSettings)
) { ) {
logger.warn( logger.warn(
'Skipped registering openJimaku because it collides with another global shortcut', 'Skipped registering openJimaku because it collides with another global shortcut',

View File

@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false, settings: false,
show: false, show: false,
hide: false, hide: false,
showVisibleOverlay: false, showVisibleOverlay: false,
hideVisibleOverlay: false, hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false, copySubtitle: false,
copySubtitleMultiple: false, copySubtitleMultiple: false,
mineSentence: false, mineSentence: false,

View File

@@ -19,9 +19,6 @@ interface RuntimeAutoUpdateOptionManagerLike {
export interface RuntimeConfigLike { export interface RuntimeConfigLike {
auto_start_overlay?: boolean; auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility: boolean; bind_visible_overlay_to_mpv_sub_visibility: boolean;
invisibleOverlay: {
startupVisibility: 'visible' | 'hidden' | 'platform-default';
};
ankiConnect?: { ankiConnect?: {
behavior?: { behavior?: {
autoUpdateNewCards?: boolean; autoUpdateNewCards?: boolean;
@@ -155,21 +152,8 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
return errors; return errors;
} }
export function getInitialInvisibleOverlayVisibility(
config: RuntimeConfigLike,
platform: NodeJS.Platform,
): boolean {
const visibility = config.invisibleOverlay.startupVisibility;
if (visibility === 'visible') return true;
if (visibility === 'hidden') return false;
if (platform === 'linux') return false;
return true;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean { export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
if (config.auto_start_overlay === true) return true; return config.auto_start_overlay === true;
if (config.invisibleOverlay.startupVisibility === 'visible') return true;
return false;
} }
export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean { export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean {

View File

@@ -101,20 +101,7 @@ export function loadSubtitlePosition(
const data = fs.readFileSync(positionPath, 'utf-8'); const data = fs.readFileSync(positionPath, 'utf-8');
const parsed = JSON.parse(data) as Partial<SubtitlePosition>; const parsed = JSON.parse(data) as Partial<SubtitlePosition>;
if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) { if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) {
const position: SubtitlePosition = { yPercent: parsed.yPercent }; return { yPercent: parsed.yPercent };
if (
typeof parsed.invisibleOffsetXPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetXPx)
) {
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
}
if (
typeof parsed.invisibleOffsetYPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetYPx)
) {
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
}
return position;
} }
return options.fallbackPosition; return options.fallbackPosition;
} catch (err) { } catch (err) {

View File

@@ -2,7 +2,6 @@ import { Config } from '../../types';
export interface ConfiguredShortcuts { export interface ConfiguredShortcuts {
toggleVisibleOverlayGlobal: string | null | undefined; toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
copySubtitle: string | null | undefined; copySubtitle: string | null | undefined;
copySubtitleMultiple: string | null | undefined; copySubtitleMultiple: string | null | undefined;
updateLastCardFromClipboard: string | null | undefined; updateLastCardFromClipboard: string | null | undefined;
@@ -33,10 +32,6 @@ export function resolveConfiguredShortcuts(
config.shortcuts?.toggleVisibleOverlayGlobal ?? config.shortcuts?.toggleVisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal, defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
), ),
toggleInvisibleOverlayGlobal: normalizeShortcut(
config.shortcuts?.toggleInvisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleInvisibleOverlayGlobal,
),
copySubtitle: normalizeShortcut( copySubtitle: normalizeShortcut(
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle, config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
), ),

View File

@@ -218,7 +218,6 @@ import {
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
createBuildEnforceOverlayLayerOrderMainDepsHandler, createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler, createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler, createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
createOverlayWindowRuntimeHandlers, createOverlayWindowRuntimeHandlers,
createOverlayRuntimeBootstrapHandlers, createOverlayRuntimeBootstrapHandlers,
@@ -234,7 +233,6 @@ import {
createSetOverlayDebugVisualizationEnabledHandler, createSetOverlayDebugVisualizationEnabledHandler,
createEnforceOverlayLayerOrderHandler, createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler, createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler, createUpdateVisibleOverlayBoundsHandler,
createLoadSubtitlePositionHandler, createLoadSubtitlePositionHandler,
createSaveSubtitlePositionHandler, createSaveSubtitlePositionHandler,
@@ -356,16 +354,15 @@ import {
runStartupBootstrapRuntime, runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
sendMpvCommandRuntime, sendMpvCommandRuntime,
setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore,
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
syncOverlayWindowLayer,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore, setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
showMpvOsdRuntime, showMpvOsdRuntime,
tokenizeSubtitle as tokenizeSubtitleCore, tokenizeSubtitle as tokenizeSubtitleCore,
triggerFieldGrouping as triggerFieldGroupingCore, triggerFieldGrouping as triggerFieldGroupingCore,
updateLastCardFromClipboard as updateLastCardFromClipboardCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services'; } from './core/services';
import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import { import {
guessAnilistMediaInfo, guessAnilistMediaInfo,
@@ -376,7 +373,10 @@ import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createMainRuntimeRegistry } from './main/runtime/registry';
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight'; import {
createEnsureOverlayMpvSubtitlesHiddenHandler,
createRestoreOverlayMpvSubtitlesHandler,
} from './main/runtime/overlay-mpv-sub-visibility';
import { import {
composeAnilistSetupHandlers, composeAnilistSetupHandlers,
composeAnilistTrackingHandlers, composeAnilistTrackingHandlers,
@@ -644,7 +644,6 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
}); });
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getModalWindow: () => overlayManager.getModalWindow(), getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => createModalWindow(), createModalWindow: () => createModalWindow(),
getModalGeometry: () => getCurrentOverlayGeometry(), getModalGeometry: () => getCurrentOverlayGeometry(),
@@ -725,13 +724,55 @@ async function initializeDiscordPresenceService(): Promise<void> {
await appState.discordPresenceService.start(); await appState.discordPresenceService.start();
publishDiscordPresence(); publishDiscordPresence();
} }
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({ const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getCurrentSubtitleData: () => appState.currentSubtitleData, getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex, setSavedSubVisibility: (visible) => {
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision, appState.overlaySavedMpvSubVisibility = visible;
getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null, },
getRevision: () => appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
appState.overlayMpvSubVisibilityRevision = revision;
},
setMpvSubVisibility: (visible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
},
logWarn: (message, error) => {
logger.warn(message, error);
},
}); });
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
appState.overlaySavedMpvSubVisibility = visible;
},
getRevision: () => appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
appState.overlayMpvSubVisibilityRevision = revision;
},
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
shouldKeepSuppressedFromVisibleOverlayBinding: () => shouldSuppressMpvSubtitlesForOverlay(),
setMpvSubVisibility: (visible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
},
});
function shouldSuppressMpvSubtitlesForOverlay(): boolean {
return (
overlayManager.getVisibleOverlayVisible() &&
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility()
);
}
function syncOverlayMpvSubtitleSuppression(): void {
if (shouldSuppressMpvSubtitlesForOverlay()) {
void ensureOverlayMpvSubtitlesHidden();
return;
}
restoreOverlayMpvSubtitles();
}
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({ const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
@@ -766,7 +807,6 @@ const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMa
const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({ const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
platform: process.platform,
defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference,
defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults,
defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl, defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl,
@@ -801,15 +841,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
return await tokenizeSubtitle(text); return await tokenizeSubtitle(text);
}, },
emitSubtitle: (payload) => { emitSubtitle: (payload) => {
const previousSubtitleText = appState.currentSubtitleData?.text ?? null;
const nextSubtitleText = payload?.text ?? null;
const subtitleChanged = previousSubtitleText !== nextSubtitleText;
appState.currentSubtitleData = payload; appState.currentSubtitleData = payload;
if (subtitleChanged) {
appState.hoveredSubtitleTokenIndex = null;
appState.hoveredSubtitleRevision += 1;
applyHoveredTokenOverlay();
}
broadcastToOverlayWindows('subtitle:set', payload); broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, { subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
@@ -850,7 +882,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
copySubtitle: () => { copySubtitle: () => {
copyCurrentSubtitle(); copyCurrentSubtitle();
}, },
toggleSecondarySubMode: () => cycleSecondarySubMode(), toggleSecondarySubMode: () => handleCycleSecondarySubMode(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(), updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(), triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
@@ -899,8 +931,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
refreshGlobalAndOverlayShortcuts(); refreshGlobalAndOverlayShortcuts();
}, },
setSecondarySubMode: (mode) => { setSecondarySubMode: (mode) => {
appState.secondarySubMode = mode; setSecondarySubMode(mode);
syncSecondaryOverlayWindowVisibility();
}, },
broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload); broadcastToOverlayWindows(channel, payload);
@@ -1023,9 +1054,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({ createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
getResolver: () => getFieldGroupingResolver(), getResolver: () => getFieldGroupingResolver(),
setResolver: (resolver) => setFieldGroupingResolver(resolver), setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => getRestoreVisibleOverlayOnModalClose: () =>
@@ -1067,26 +1096,40 @@ const mediaRuntime = createMediaRuntimeService(
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({ createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown; appState.trackerNotReadyWarningShown = shown;
}, },
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel: (window) => {
ensureOverlayWindowLevel(window); ensureOverlayWindowLevel(window);
}, },
syncPrimaryOverlayWindowLayer: (layer) => {
syncPrimaryOverlayWindowLayer(layer);
},
enforceOverlayLayerOrder: () => { enforceOverlayLayerOrder: () => {
enforceOverlayLayerOrder(); enforceOverlayLayerOrder();
}, },
syncOverlayShortcuts: () => { syncOverlayShortcuts: () => {
overlayShortcutsRuntime.syncOverlayShortcuts(); overlayShortcutsRuntime.syncOverlayShortcuts();
}, },
isMacOSPlatform: () => process.platform === 'darwin',
showOverlayLoadingOsd: (message: string) => {
showMpvOsd(message);
},
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
return {
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
};
},
})(), })(),
); );
@@ -1165,7 +1208,6 @@ const buildSetOverlayDebugVisualizationEnabledMainDepsHandler =
setCurrentEnabled: (next) => { setCurrentEnabled: (next) => {
appState.overlayDebugVisualizationEnabled = next; appState.overlayDebugVisualizationEnabled = next;
}, },
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
}); });
const setOverlayDebugVisualizationEnabledMainDeps = const setOverlayDebugVisualizationEnabledMainDeps =
buildSetOverlayDebugVisualizationEnabledMainDepsHandler(); buildSetOverlayDebugVisualizationEnabledMainDepsHandler();
@@ -1826,6 +1868,9 @@ const {
destroyTray: () => destroyTray(), destroyTray: () => destroyTray(),
stopConfigHotReload: () => configHotReloadRuntime.stop(), stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
},
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => subtitleWsService.stop(), stopSubtitleWebsocket: () => subtitleWsService.stop(),
stopTexthookerService: () => texthookerService.stop(), stopTexthookerService: () => texthookerService.stop(),
@@ -1870,14 +1915,11 @@ const {
createMainWindow: () => { createMainWindow: () => {
createMainWindow(); createMainWindow();
}, },
createInvisibleWindow: () => {
createInvisibleWindow();
},
updateVisibleOverlayVisibility: () => { updateVisibleOverlayVisibility: () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
updateInvisibleOverlayVisibility: () => { syncOverlayMpvSubtitleSuppression: () => {
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); syncOverlayMpvSubtitleSuppression();
}, },
}, },
}); });
@@ -1934,8 +1976,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
); );
}, },
setSecondarySubMode: (mode: SecondarySubMode) => { setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode; setSecondarySubMode(mode);
syncSecondaryOverlayWindowVisibility();
}, },
defaultSecondarySubMode: 'hover', defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
@@ -2124,6 +2165,9 @@ const {
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
mediaRuntime.updateCurrentMediaPath(path); mediaRuntime.updateCurrentMediaPath(path);
}, },
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
},
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => { resetAnilistMediaTracking: (mediaKey) => {
resetAnilistMediaTracking(mediaKey); resetAnilistMediaTracking(mediaKey);
@@ -2149,6 +2193,9 @@ const {
updateSubtitleRenderMetrics: (patch) => { updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>); updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
}, },
syncOverlayMpvSubtitleSuppression: () => {
syncOverlayMpvSubtitleSuppression();
},
}, },
mpvClientRuntimeServiceFactoryMainDeps: { mpvClientRuntimeServiceFactoryMainDeps: {
createClient: MpvIpcClient, createClient: MpvIpcClient,
@@ -2170,8 +2217,8 @@ const {
appState.mpvSubtitleRenderMetrics = metrics; appState.mpvSubtitleRenderMetrics = metrics;
}, },
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: (metrics) => { broadcastMetrics: () => {
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics); // no renderer consumer for subtitle render metrics updates at present
}, },
}, },
tokenizer: { tokenizer: {
@@ -2276,52 +2323,21 @@ function getCurrentOverlayGeometry(): WindowGeometry {
return getOverlayGeometryFallback(); return getOverlayGeometryFallback();
} }
function syncSecondaryOverlayWindowVisibility(): void { function applyOverlayRegions(geometry: WindowGeometry): void {
const secondaryWindow = overlayManager.getSecondaryWindow();
if (!secondaryWindow || secondaryWindow.isDestroyed()) return;
if (appState.secondarySubMode === 'hidden') {
secondaryWindow.setIgnoreMouseEvents(true, { forward: true });
secondaryWindow.hide();
return;
}
secondaryWindow.setIgnoreMouseEvents(false);
ensureOverlayWindowLevel(secondaryWindow);
if (typeof secondaryWindow.showInactive === 'function') {
secondaryWindow.showInactive();
} else {
secondaryWindow.show();
}
}
function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeometry): void {
lastOverlayWindowGeometry = geometry; lastOverlayWindowGeometry = geometry;
const regions = splitOverlayGeometryForSecondaryBar(geometry); overlayManager.setOverlayWindowBounds(geometry);
overlayManager.setOverlayWindowBounds(layer, regions.primary);
overlayManager.setSecondaryWindowBounds(regions.secondary);
overlayManager.setModalWindowBounds(geometry); overlayManager.setModalWindowBounds(geometry);
syncSecondaryOverlayWindowVisibility();
} }
const buildUpdateVisibleOverlayBoundsMainDepsHandler = const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
}); });
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
updateVisibleOverlayBoundsMainDeps, updateVisibleOverlayBoundsMainDeps,
); );
const buildUpdateInvisibleOverlayBoundsMainDepsHandler =
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
});
const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler();
const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler(
updateInvisibleOverlayBoundsMainDeps,
);
const buildEnsureOverlayWindowLevelMainDepsHandler = const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({ createBuildEnsureOverlayWindowLevelMainDepsHandler({
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
@@ -2331,21 +2347,23 @@ const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
ensureOverlayWindowLevelMainDeps, ensureOverlayWindowLevelMainDeps,
); );
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
syncOverlayWindowLayer(mainWindow, layer);
}
const buildEnforceOverlayLayerOrderMainDepsHandler = const buildEnforceOverlayLayerOrderMainDepsHandler =
createBuildEnforceOverlayLayerOrderMainDepsHandler({ createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: (params) => enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({ enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible, visibleOverlayVisible: params.visibleOverlayVisible,
invisibleOverlayVisible: params.invisibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null, mainWindow: params.mainWindow as BrowserWindow | null,
invisibleWindow: params.invisibleWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel: (window) =>
params.ensureOverlayWindowLevel(window as BrowserWindow), params.ensureOverlayWindowLevel(window as BrowserWindow),
}), }),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
}); });
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
@@ -2361,7 +2379,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
} }
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow { function createOverlayWindow(kind: 'visible' | 'modal'): BrowserWindow {
return createOverlayWindowHandler(kind); return createOverlayWindowHandler(kind);
} }
@@ -2375,25 +2393,9 @@ function createModalWindow(): BrowserWindow {
return window; return window;
} }
function createSecondaryWindow(): BrowserWindow {
const existingWindow = overlayManager.getSecondaryWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return existingWindow;
}
const window = createSecondaryWindowHandler();
applyOverlayRegions('visible', getCurrentOverlayGeometry());
return window;
}
function createMainWindow(): BrowserWindow { function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler(); return createMainWindowHandler();
createSecondaryWindow();
return window;
} }
function createInvisibleWindow(): BrowserWindow {
return createInvisibleWindowHandler();
}
function resolveTrayIconPath(): string | null { function resolveTrayIconPath(): string | null {
return resolveTrayIconPathHandler(); return resolveTrayIconPathHandler();
} }
@@ -2412,6 +2414,7 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler(); initializeOverlayRuntimeHandler();
syncOverlayMpvSubtitleSuppression();
} }
function openYomitanSettings(): void { function openYomitanSettings(): void {
@@ -2441,7 +2444,6 @@ const {
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore, registerGlobalShortcutsCore,
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
isDev, isDev,
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
@@ -2495,8 +2497,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: { cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => appState.secondarySubMode, getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => { setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode; setSecondarySubMode(mode);
syncSecondaryOverlayWindowVisibility();
}, },
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => { setLastSecondarySubToggleAtMs: (timestampMs: number) => {
@@ -2510,6 +2511,14 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
}); });
function setSecondarySubMode(mode: SecondarySubMode): void {
appState.secondarySubMode = mode;
}
function handleCycleSecondarySubMode(): void {
cycleSecondarySubMode();
}
async function triggerSubsyncFromConfig(): Promise<void> { async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig(); await subsyncRuntime.triggerFromConfig();
} }
@@ -2613,9 +2622,7 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler(
); );
const { const {
setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, setVisibleOverlayVisible: setVisibleOverlayVisibleHandler,
setInvisibleOverlayVisible: setInvisibleOverlayVisibleHandler,
toggleVisibleOverlay: toggleVisibleOverlayHandler, toggleVisibleOverlay: toggleVisibleOverlayHandler,
toggleInvisibleOverlay: toggleInvisibleOverlayHandler,
setOverlayVisible: setOverlayVisibleHandler, setOverlayVisible: setOverlayVisibleHandler,
toggleOverlay: toggleOverlayHandler, toggleOverlay: toggleOverlayHandler,
} = createOverlayVisibilityRuntime({ } = createOverlayVisibilityRuntime({
@@ -2625,29 +2632,8 @@ const {
overlayManager.setVisibleOverlayVisible(nextVisible); overlayManager.setVisibleOverlayVisible(nextVisible);
}, },
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () =>
overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
setMpvSubVisibility: (mpvSubVisible) => {
setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible);
},
},
setInvisibleOverlayVisibleDeps: {
setInvisibleOverlayVisibleCore,
setInvisibleOverlayVisibleState: (nextVisible) => {
overlayManager.setInvisibleOverlayVisible(nextVisible);
},
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () =>
overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(),
}, },
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
}); });
const buildHandleOverlayModalClosedMainDepsHandler = const buildHandleOverlayModalClosedMainDepsHandler =
@@ -2707,10 +2693,8 @@ const {
showMpvOsd: (text: string) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
}, },
mainDeps: { mainDeps: {
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
focusMainWindow: () => { focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
@@ -2721,13 +2705,15 @@ const {
onOverlayModalClosed: (modal) => { onOverlayModalClosed: (modal) => {
handleOverlayModalClosed(modal); handleOverlayModalClosed(modal);
}, },
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(), quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => { getSubtitleStyle: () => {
const resolvedConfig = getResolvedConfig(); const resolvedConfig = getResolvedConfig();
@@ -2744,9 +2730,6 @@ const {
reportOverlayContentBounds: (payload: unknown) => { reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload); overlayContentMeasurementStore.report(payload);
}, },
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
reportHoveredSubtitleToken(tokenIndex);
},
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
clearAnilistToken: () => anilistStateRuntime.clearTokenState(), clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
openAnilistSetup: () => openAnilistSetupWindow(), openAnilistSetup: () => openAnilistSetupWindow(),
@@ -2800,9 +2783,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
showMpvOsd: (text: string) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(), copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(), mineSentenceCard: () => mineSentenceCard(),
@@ -2821,7 +2802,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => app.quit(), stopApp: () => app.quit(),
@@ -2835,40 +2816,29 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
const { const {
createOverlayWindow: createOverlayWindowHandler, createOverlayWindow: createOverlayWindowHandler,
createMainWindow: createMainWindowHandler, createMainWindow: createMainWindowHandler,
createInvisibleWindow: createInvisibleWindowHandler,
createSecondaryWindow: createSecondaryWindowHandler,
createModalWindow: createModalWindowHandler, createModalWindow: createModalWindowHandler,
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({ } = createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev, isDev,
getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled,
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) => isOverlayVisible: (windowKind) =>
windowKind === 'visible' windowKind === 'visible'
? overlayManager.getVisibleOverlayVisible() ? overlayManager.getVisibleOverlayVisible()
: windowKind === 'invisible' : false,
? overlayManager.getInvisibleOverlayVisible()
: false,
tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
onWindowClosed: (windowKind) => { onWindowClosed: (windowKind) => {
if (windowKind === 'visible') { if (windowKind === 'visible') {
overlayManager.setMainWindow(null); overlayManager.setMainWindow(null);
} else if (windowKind === 'invisible') {
overlayManager.setInvisibleWindow(null);
} else if (windowKind === 'secondary') {
overlayManager.setSecondaryWindow(null);
} else { } else {
overlayManager.setModalWindow(null); overlayManager.setModalWindow(null);
} }
}, },
}, },
setMainWindow: (window) => overlayManager.setMainWindow(window), setMainWindow: (window) => overlayManager.setMainWindow(window),
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window), setModalWindow: (window) => overlayManager.setModalWindow(window),
}); });
const { const {
@@ -2948,24 +2918,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
appState, appState,
overlayManager: { overlayManager: {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
}, },
overlayVisibilityRuntime: { overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(), overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
}, },
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
}, },
getInitialInvisibleOverlayVisibility: () =>
configDerivedRuntime.getInitialInvisibleOverlayVisibility(),
createMainWindow: () => createMainWindow(), createMainWindow: () => createMainWindow(),
createInvisibleWindow: () => createInvisibleWindow(),
registerGlobalShortcuts: () => registerGlobalShortcuts(), registerGlobalShortcuts: () => registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry),
getOverlayWindows: () => getOverlayWindows(), getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification, showDesktopNotification,
@@ -2975,9 +2938,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
initializeOverlayRuntimeBootstrapDeps: { initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore, initializeOverlayRuntimeCore,
setInvisibleOverlayVisible: (visible) => {
overlayManager.setInvisibleOverlayVisible(visible);
},
setOverlayRuntimeInitialized: (initialized) => { setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized; appState.overlayRuntimeInitialized = initialized;
}, },
@@ -3035,39 +2995,26 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow(); createMainWindow();
} }
const invisibleWindow = overlayManager.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) {
createInvisibleWindow();
}
} }
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
setVisibleOverlayVisibleHandler(visible); setVisibleOverlayVisibleHandler(visible);
} syncOverlayMpvSubtitleSuppression();
function setInvisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
setInvisibleOverlayVisibleHandler(visible);
if (visible) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
} }
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
toggleVisibleOverlayHandler(); toggleVisibleOverlayHandler();
} syncOverlayMpvSubtitleSuppression();
function toggleInvisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
toggleInvisibleOverlayHandler();
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
setOverlayVisibleHandler(visible); setOverlayVisibleHandler(visible);
syncOverlayMpvSubtitleSuppression();
} }
function toggleOverlay(): void { function toggleOverlay(): void {
toggleOverlayHandler(); toggleOverlayHandler();
syncOverlayMpvSubtitleSuppression();
} }
function handleOverlayModalClosed(modal: OverlayHostedModal): void { function handleOverlayModalClosed(modal: OverlayHostedModal): void {
handleOverlayModalClosedHandler(modal); handleOverlayModalClosedHandler(modal);
@@ -3077,11 +3024,6 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcHandler(command); handleMpvCommandFromIpcHandler(command);
} }
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
appState.hoveredSubtitleTokenIndex = tokenIndex;
applyHoveredTokenOverlay();
}
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> { async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>; return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
} }

View File

@@ -17,9 +17,7 @@ export interface CliCommandRuntimeServiceContext {
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>; mineSentenceCard: () => Promise<void>;
@@ -74,9 +72,7 @@ function createCliCommandDepsFromContext(
isInitialized: context.isOverlayInitialized, isInitialized: context.isOverlayInitialized,
initialize: context.initializeOverlay, initialize: context.initializeOverlay,
toggleVisible: context.toggleVisibleOverlay, toggleVisible: context.toggleVisibleOverlay,
toggleInvisible: context.toggleInvisibleOverlay,
setVisible: context.setVisibleOverlay, setVisible: context.setVisibleOverlay,
setInvisible: context.setInvisibleOverlay,
}, },
mining: { mining: {
copyCurrentSubtitle: context.copyCurrentSubtitle, copyCurrentSubtitle: context.copyCurrentSubtitle,

View File

@@ -53,11 +53,10 @@ export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): Subs
} }
export interface MainIpcRuntimeServiceDepsParams { export interface MainIpcRuntimeServiceDepsParams {
getInvisibleWindow: IpcDepsRuntimeOptions['getInvisibleWindow'];
getMainWindow: IpcDepsRuntimeOptions['getMainWindow']; getMainWindow: IpcDepsRuntimeOptions['getMainWindow'];
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
getInvisibleOverlayVisibility: IpcDepsRuntimeOptions['getInvisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp']; quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
@@ -65,7 +64,6 @@ export interface MainIpcRuntimeServiceDepsParams {
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle']; getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition']; saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
@@ -81,7 +79,6 @@ export interface MainIpcRuntimeServiceDepsParams {
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption']; setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption']; cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds']; reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus']; getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken']; clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup']; openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
@@ -132,9 +129,7 @@ export interface CliCommandRuntimeServiceDepsParams {
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized']; isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize']; initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible']; toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
toggleInvisible: CliCommandDepsRuntimeOptions['overlay']['toggleInvisible'];
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible']; setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
setInvisible: CliCommandDepsRuntimeOptions['overlay']['setInvisible'];
}; };
mining: { mining: {
copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle']; copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle'];
@@ -192,18 +187,16 @@ export function createMainIpcRuntimeServiceDeps(
params: MainIpcRuntimeServiceDepsParams, params: MainIpcRuntimeServiceDepsParams,
): IpcDepsRuntimeOptions { ): IpcDepsRuntimeOptions {
return { return {
getInvisibleWindow: params.getInvisibleWindow,
getMainWindow: params.getMainWindow, getMainWindow: params.getMainWindow,
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened,
openYomitanSettings: params.openYomitanSettings, openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp, quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay, toggleVisibleOverlay: params.toggleVisibleOverlay,
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss, getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
getSubtitlePosition: params.getSubtitlePosition, getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle, getSubtitleStyle: params.getSubtitleStyle,
saveSubtitlePosition: params.saveSubtitlePosition, saveSubtitlePosition: params.saveSubtitlePosition,
@@ -220,7 +213,6 @@ export function createMainIpcRuntimeServiceDeps(
setRuntimeOption: params.setRuntimeOption, setRuntimeOption: params.setRuntimeOption,
cycleRuntimeOption: params.cycleRuntimeOption, cycleRuntimeOption: params.cycleRuntimeOption,
reportOverlayContentBounds: params.reportOverlayContentBounds, reportOverlayContentBounds: params.reportOverlayContentBounds,
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
getAnilistStatus: params.getAnilistStatus, getAnilistStatus: params.getAnilistStatus,
clearAnilistToken: params.clearAnilistToken, clearAnilistToken: params.clearAnilistToken,
openAnilistSetup: params.openAnilistSetup, openAnilistSetup: params.openAnilistSetup,
@@ -279,9 +271,7 @@ export function createCliCommandRuntimeServiceDeps(
isInitialized: params.overlay.isInitialized, isInitialized: params.overlay.isInitialized,
initialize: params.overlay.initialize, initialize: params.overlay.initialize,
toggleVisible: params.overlay.toggleVisible, toggleVisible: params.overlay.toggleVisible,
toggleInvisible: params.overlay.toggleInvisible,
setVisible: params.overlay.setVisible, setVisible: params.overlay.setVisible,
setInvisible: params.overlay.setInvisible,
}, },
mining: { mining: {
copyCurrentSubtitle: params.mining.copyCurrentSubtitle, copyCurrentSubtitle: params.mining.copyCurrentSubtitle,

View File

@@ -12,6 +12,7 @@ type MockWindow = {
hideCount: number; hideCount: number;
sent: unknown[][]; sent: unknown[][];
loading: boolean; loading: boolean;
url: string;
loadCallbacks: Array<() => void>; loadCallbacks: Array<() => void>;
}; };
@@ -19,7 +20,8 @@ function createMockWindow(): MockWindow & {
isDestroyed: () => boolean; isDestroyed: () => boolean;
isVisible: () => boolean; isVisible: () => boolean;
isFocused: () => boolean; isFocused: () => boolean;
setIgnoreMouseEvents: (ignore: boolean) => void; getURL: () => string;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
getShowCount: () => number; getShowCount: () => number;
getHideCount: () => number; getHideCount: () => number;
show: () => void; show: () => void;
@@ -28,6 +30,7 @@ function createMockWindow(): MockWindow & {
webContents: { webContents: {
focused: boolean; focused: boolean;
isLoading: () => boolean; isLoading: () => boolean;
getURL: () => string;
send: (channel: string, payload?: unknown) => void; send: (channel: string, payload?: unknown) => void;
isFocused: () => boolean; isFocused: () => boolean;
once: (event: 'did-finish-load', cb: () => void) => void; once: (event: 'did-finish-load', cb: () => void) => void;
@@ -44,14 +47,16 @@ function createMockWindow(): MockWindow & {
hideCount: 0, hideCount: 0,
sent: [], sent: [],
loading: false, loading: false,
url: 'file:///overlay/index.html?layer=modal',
loadCallbacks: [], loadCallbacks: [],
}; };
return { const window = {
...state, ...state,
isDestroyed: () => state.destroyed, isDestroyed: () => state.destroyed,
isVisible: () => state.visible, isVisible: () => state.visible,
isFocused: () => state.focused, isFocused: () => state.focused,
setIgnoreMouseEvents: (ignore: boolean) => { getURL: () => state.url,
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
state.ignoreMouseEvents = ignore; state.ignoreMouseEvents = ignore;
}, },
getShowCount: () => state.showCount, getShowCount: () => state.showCount,
@@ -69,7 +74,8 @@ function createMockWindow(): MockWindow & {
}, },
webContents: { webContents: {
isLoading: () => state.loading, isLoading: () => state.loading,
send: (channel, payload) => { getURL: () => state.url,
send: (channel: string, payload?: unknown) => {
if (payload === undefined) { if (payload === undefined) {
state.sent.push([channel]); state.sent.push([channel]);
return; return;
@@ -78,7 +84,7 @@ function createMockWindow(): MockWindow & {
}, },
focused: false, focused: false,
isFocused: () => state.webContentsFocused, isFocused: () => state.webContentsFocused,
once: (_event, cb) => { once: (_event: 'did-finish-load', cb: () => void) => {
state.loadCallbacks.push(cb); state.loadCallbacks.push(cb);
}, },
focus: () => { focus: () => {
@@ -86,6 +92,29 @@ function createMockWindow(): MockWindow & {
}, },
}, },
}; };
Object.defineProperty(window, 'loading', {
get: () => state.loading,
set: (value: boolean) => {
state.loading = value;
},
});
Object.defineProperty(window, 'url', {
get: () => state.url,
set: (value: string) => {
state.url = value;
},
});
Object.defineProperty(window, 'ignoreMouseEvents', {
get: () => state.ignoreMouseEvents,
set: (value: boolean) => {
state.ignoreMouseEvents = value;
},
});
return window;
} }
test('sendToActiveOverlayWindow targets modal window with full geometry and tracks close restore', () => { test('sendToActiveOverlayWindow targets modal window with full geometry and tracks close restore', () => {
@@ -93,7 +122,6 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
const calls: string[] = []; const calls: string[] = [];
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getInvisibleWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
createModalWindow: () => { createModalWindow: () => {
calls.push('create-modal-window'); calls.push('create-modal-window');
@@ -111,6 +139,8 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
assert.equal(sent, true); assert.equal(sent, true);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true); assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true);
assert.deepEqual(calls, ['bounds:10,20,300,200']); assert.deepEqual(calls, ['bounds:10,20,300,200']);
assert.equal(window.getShowCount(), 0);
runtime.notifyOverlayModalOpened('runtime-options');
assert.equal(window.getShowCount(), 1); assert.equal(window.getShowCount(), 1);
assert.equal(window.isFocused(), true); assert.equal(window.isFocused(), true);
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
@@ -121,7 +151,6 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
let modalWindow: ReturnType<typeof createMockWindow> | null = null; let modalWindow: ReturnType<typeof createMockWindow> | null = null;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getInvisibleWindow: () => null,
getModalWindow: () => modalWindow as never, getModalWindow: () => modalWindow as never,
createModalWindow: () => { createModalWindow: () => {
modalWindow = window; modalWindow = window;
@@ -135,14 +164,47 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku' }), runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku' }),
true, true,
); );
assert.equal(window.getShowCount(), 0);
runtime.notifyOverlayModalOpened('jimaku');
assert.equal(window.getShowCount(), 1);
assert.deepEqual(window.sent, [['jimaku:open']]); assert.deepEqual(window.sent, [['jimaku:open']]);
}); });
test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => {
const window = createMockWindow();
window.url = '';
window.loading = true;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 10, y: 20, width: 300, height: 200 }),
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.loadCallbacks[0]!();
runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(window.sent, [['runtime-options:open']]);
assert.equal(window.getShowCount(), 1);
});
test('handleOverlayModalClosed hides modal window only after all pending modals close', () => { test('handleOverlayModalClosed hides modal window only after all pending modals close', () => {
const window = createMockWindow(); const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getInvisibleWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
createModalWindow: () => window as never, createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
@@ -163,13 +225,33 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
assert.equal(window.getHideCount(), 1); assert.equal(window.getHideCount(), 1);
}); });
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => mainWindow as never,
getModalWindow: () => null,
createModalWindow: () => {
throw new Error('modal window should not be created when main overlay is visible');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.deepEqual(mainWindow.sent, [['runtime-options:open']]);
});
test('modal runtime notifies callers when modal input state becomes active/inactive', () => { test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
const window = createMockWindow(); const window = createMockWindow();
const state: boolean[] = []; const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService(
{ {
getMainWindow: () => null, getMainWindow: () => null,
getInvisibleWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
createModalWindow: () => window as never, createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
@@ -188,6 +270,8 @@ test('modal runtime notifies callers when modal input state becomes active/inact
runtime.sendToActiveOverlayWindow('subsync:open-manual', { sourceTracks: [] }, { runtime.sendToActiveOverlayWindow('subsync:open-manual', { sourceTracks: [] }, {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}); });
assert.deepEqual(state, []);
runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(state, [true]); assert.deepEqual(state, [true]);
runtime.handleOverlayModalClosed('runtime-options'); runtime.handleOverlayModalClosed('runtime-options');
@@ -197,11 +281,36 @@ test('modal runtime notifies callers when modal input state becomes active/inact
assert.deepEqual(state, [true, false]); assert.deepEqual(state, [true, false]);
}); });
test('handleOverlayModalClosed resets modal state even when modal window does not exist', () => {
const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => null,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
onModalStateChange: (active: boolean): void => {
state.push(active);
},
},
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options');
assert.deepEqual(state, [true, false]);
});
test('handleOverlayModalClosed hides modal window for single kiku modal', () => { test('handleOverlayModalClosed hides modal window for single kiku modal', () => {
const window = createMockWindow(); const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getInvisibleWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
createModalWindow: () => window as never, createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
@@ -216,3 +325,36 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () =>
assert.equal(window.getHideCount(), 1); assert.equal(window.getHideCount(), 1);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
}); });
test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => {
const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
window.loading = true;
window.url = '';
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
restoreOnModalClose: 'jimaku',
});
assert.equal(sent, true);
assert.equal(window.ignoreMouseEvents, false);
await new Promise<void>((resolve) => {
setTimeout(resolve, 260);
});
assert.equal(window.getShowCount(), 1);
assert.equal(window.ignoreMouseEvents, true);
runtime.notifyOverlayModalOpened('jimaku');
assert.equal(window.ignoreMouseEvents, false);
});

View File

@@ -1,12 +1,12 @@
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku'; type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
type OverlayHostLayer = 'visible' | 'invisible';
export interface OverlayWindowResolver { export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
createModalWindow: () => BrowserWindow | null; createModalWindow: () => BrowserWindow | null;
getModalGeometry: () => WindowGeometry; getModalGeometry: () => WindowGeometry;
@@ -21,6 +21,7 @@ export interface OverlayModalRuntime {
) => boolean; ) => boolean;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>; getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
} }
@@ -34,6 +35,8 @@ export function createOverlayModalRuntimeService(
): OverlayModalRuntime { ): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>(); const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
let modalActive = false; let modalActive = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
const notifyModalStateChange = (nextState: boolean): void => { const notifyModalStateChange = (nextState: boolean): void => {
if (modalActive === nextState) return; if (modalActive === nextState) return;
@@ -53,44 +56,116 @@ export function createOverlayModalRuntimeService(
return createdWindow; return createdWindow;
}; };
const getTargetOverlayWindow = (): { const getTargetOverlayWindow = (): BrowserWindow | null => {
window: BrowserWindow;
layer: OverlayHostLayer;
} | null => {
const visibleMainWindow = deps.getMainWindow(); const visibleMainWindow = deps.getMainWindow();
const invisibleWindow = deps.getInvisibleWindow();
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) { if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
return { window: visibleMainWindow, layer: 'visible' }; return visibleMainWindow;
} }
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
return { window: invisibleWindow, layer: 'invisible' };
}
return null; return null;
}; };
const showModalWindow = (window: BrowserWindow): void => { const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
window.show(); if (window.webContents.isLoading()) {
window.setIgnoreMouseEvents(false); return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
};
const elevateModalWindow = (window: BrowserWindow): void => {
if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.moveTop();
};
const sendOrQueueForWindow = (
window: BrowserWindow,
sendNow: (window: BrowserWindow) => void,
): void => {
if (isWindowReadyForIpc(window)) {
sendNow(window);
return;
}
window.webContents.once('did-finish-load', () => {
if (!window.isDestroyed() && !window.webContents.isLoading()) {
sendNow(window);
}
});
};
const showModalWindow = (
window: BrowserWindow,
options: {
passThroughMouseEvents: boolean;
} = { passThroughMouseEvents: false },
): void => {
if (!window.isVisible()) {
window.show();
}
elevateModalWindow(window);
if (options.passThroughMouseEvents) {
window.setIgnoreMouseEvents(true, { forward: true });
} else {
window.setIgnoreMouseEvents(false);
}
window.focus(); window.focus();
if (!window.webContents.isFocused()) { if (!window.webContents.isFocused()) {
window.webContents.focus(); window.webContents.focus();
} }
}; };
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => { const ensureModalWindowInteractive = (window: BrowserWindow): void => {
if (layer === 'invisible' && typeof window.showInactive === 'function') { if (window.isVisible()) {
window.showInactive(); window.setIgnoreMouseEvents(false);
} else { if (!window.isFocused()) {
window.show(); window.focus();
}
if (!window.webContents.isFocused()) {
window.webContents.focus();
}
elevateModalWindow(window);
return;
} }
showModalWindow(window);
};
const showOverlayWindowForModal = (window: BrowserWindow): void => {
window.show();
if (!window.isFocused()) { if (!window.isFocused()) {
window.focus(); window.focus();
} }
}; };
const clearPendingModalWindowReveal = (): void => {
if (pendingModalWindowRevealTimeout === null) {
pendingModalWindowReveal = null;
return;
}
clearTimeout(pendingModalWindowRevealTimeout);
pendingModalWindowRevealTimeout = null;
pendingModalWindowReveal = null;
};
const scheduleModalWindowReveal = (window: BrowserWindow): void => {
pendingModalWindowReveal = window;
if (pendingModalWindowRevealTimeout !== null) {
return;
}
pendingModalWindowRevealTimeout = setTimeout(() => {
const targetWindow = pendingModalWindowReveal;
clearPendingModalWindowReveal();
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
return;
}
showModalWindow(targetWindow, { passThroughMouseEvents: false });
}, MODAL_REVEAL_FALLBACK_DELAY_MS);
};
const sendToActiveOverlayWindow = ( const sendToActiveOverlayWindow = (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
@@ -99,6 +174,7 @@ export function createOverlayModalRuntimeService(
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
const sendNow = (window: BrowserWindow): void => { const sendNow = (window: BrowserWindow): void => {
ensureModalWindowInteractive(window);
if (payload === undefined) { if (payload === undefined) {
window.webContents.send(channel); window.webContents.send(channel);
} else { } else {
@@ -107,55 +183,43 @@ export function createOverlayModalRuntimeService(
}; };
if (restoreOnModalClose) { if (restoreOnModalClose) {
const modalWindow = resolveModalWindow();
if (!modalWindow) return false;
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
const wasModalActive = restoreVisibleOverlayOnModalClose.size > 0;
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
if (!wasModalActive) { const mainWindow = getTargetOverlayWindow();
notifyModalStateChange(true); if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
} sendOrQueueForWindow(mainWindow, (window) => {
if (payload === undefined) {
if (!wasVisible) { window.webContents.send(channel);
showModalWindow(modalWindow); } else {
} else if (!modalWindow.isFocused()) { window.webContents.send(channel, payload);
showModalWindow(modalWindow);
}
if (modalWindow.webContents.isLoading()) {
modalWindow.webContents.once('did-finish-load', () => {
if (modalWindow && !modalWindow.isDestroyed() && !modalWindow.webContents.isLoading()) {
sendNow(modalWindow);
} }
}); });
return true; return true;
} }
sendNow(modalWindow); const modalWindow = resolveModalWindow();
if (!modalWindow) return false;
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
if (!wasVisible) {
scheduleModalWindowReveal(modalWindow);
} else if (!modalWindow.isFocused()) {
showModalWindow(modalWindow);
}
sendOrQueueForWindow(modalWindow, sendNow);
return true; return true;
} }
const target = getTargetOverlayWindow(); const target = getTargetOverlayWindow();
if (!target) return false; if (!target) return false;
const { window: targetWindow, layer } = target; const wasVisible = target.isVisible();
const wasVisible = targetWindow.isVisible();
if (!wasVisible) { if (!wasVisible) {
showOverlayWindowForModal(targetWindow, layer); showOverlayWindowForModal(target);
} }
if (targetWindow.webContents.isLoading()) { sendOrQueueForWindow(target, sendNow);
targetWindow.webContents.once('did-finish-load', () => {
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
sendNow(targetWindow);
}
});
return true;
}
sendNow(targetWindow);
return true; return true;
}; };
@@ -169,19 +233,44 @@ export function createOverlayModalRuntimeService(
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal); restoreVisibleOverlayOnModalClose.delete(modal);
const modalWindow = deps.getModalWindow(); const modalWindow = deps.getModalWindow();
if (!modalWindow || modalWindow.isDestroyed()) return;
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal();
notifyModalStateChange(false); notifyModalStateChange(false);
if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.hide();
}
} }
if (restoreVisibleOverlayOnModalClose.size === 0) { };
modalWindow.hide();
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
notifyModalStateChange(true);
const targetWindow = deps.getModalWindow();
clearPendingModalWindowReveal();
if (!targetWindow || targetWindow.isDestroyed()) {
return;
} }
if (targetWindow.isVisible()) {
targetWindow.setIgnoreMouseEvents(false);
elevateModalWindow(targetWindow);
if (!targetWindow.isFocused()) {
targetWindow.focus();
}
if (!targetWindow.webContents.isFocused()) {
targetWindow.webContents.focus();
}
return;
}
showModalWindow(targetWindow);
}; };
return { return {
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
handleOverlayModalClosed, handleOverlayModalClosed,
notifyOverlayModalOpened,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
}; };
} }

View File

@@ -2,50 +2,31 @@ import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../window-trackers'; import type { BaseWindowTracker } from '../window-trackers';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
import { import { updateVisibleOverlayVisibility } from '../core/services';
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility,
} from '../core/services';
export interface OverlayVisibilityRuntimeDeps { export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getWindowTracker: () => BaseWindowTracker | null; getWindowTracker: () => BaseWindowTracker | null;
getTrackerNotReadyWarningShown: () => boolean; getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void; enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
isMacOSPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
resolveFallbackBounds: () => WindowGeometry;
} }
export interface OverlayVisibilityRuntimeService { export interface OverlayVisibilityRuntimeService {
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
} }
export function createOverlayVisibilityRuntimeService( export function createOverlayVisibilityRuntimeService(
deps: OverlayVisibilityRuntimeDeps, deps: OverlayVisibilityRuntimeDeps,
): OverlayVisibilityRuntimeService { ): OverlayVisibilityRuntimeService {
const hasInvisibleWindow = (): boolean => {
const invisibleWindow = deps.getInvisibleWindow();
return Boolean(invisibleWindow && !invisibleWindow.isDestroyed());
};
const setIgnoreMouseEvents = (
ignore: boolean,
options?: Parameters<BrowserWindow['setIgnoreMouseEvents']>[1],
): void => {
const invisibleWindow = deps.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(ignore, options);
};
return { return {
updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({ updateVisibleOverlayVisibility({
@@ -59,31 +40,13 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry), deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
}); isMacOSPlatform: deps.isMacOSPlatform(),
}, showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
updateInvisibleOverlayVisibility(): void {
updateInvisibleOverlayVisibility({
invisibleWindow: deps.getInvisibleWindow(),
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
windowTracker: deps.getWindowTracker(),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
},
syncInvisibleOverlayMousePassthrough(): void {
syncInvisibleOverlayMousePassthrough({
hasInvisibleWindow,
setIgnoreMouseEvents,
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
}); });
}, },
}; };

View File

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

View File

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

View File

@@ -19,14 +19,12 @@ test('restore windows on activate deps builder maps all restoration callbacks',
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({ const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
createMainWindow: () => calls.push('main'), createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible'), updateVisibleOverlayVisibility: () => calls.push('visible'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'), syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
})(); })();
deps.createMainWindow(); deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility(); deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility(); deps.syncOverlayMpvSubtitleSuppression();
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']); assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']);
}); });

View File

@@ -10,14 +10,12 @@ export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: { export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
createMainWindow: () => void; createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void; syncOverlayMpvSubtitleSuppression: () => void;
}) { }) {
return () => ({ return () => ({
createMainWindow: () => deps.createMainWindow(), createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
}); });
} }

View File

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

View File

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

View File

@@ -50,26 +50,18 @@ test('initialize overlay runtime main deps map build options and callbacks', ()
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntimeCore: (value) => { initializeOverlayRuntimeCore: (value) => {
calls.push(`core:${JSON.stringify(value)}`); calls.push(`core:${JSON.stringify(value)}`);
return { invisibleOverlayVisible: true };
}, },
buildOptions: () => options, buildOptions: () => options,
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`), setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
startBackgroundWarmups: () => calls.push('warmups'), startBackgroundWarmups: () => calls.push('warmups'),
})(); })();
assert.equal(deps.isOverlayRuntimeInitialized(), false); assert.equal(deps.isOverlayRuntimeInitialized(), false);
assert.equal(deps.buildOptions(), options); assert.equal(deps.buildOptions(), options);
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true }); assert.equal(deps.initializeOverlayRuntimeCore(options), undefined);
deps.setInvisibleOverlayVisible(true);
deps.setOverlayRuntimeInitialized(true); deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups(); deps.startBackgroundWarmups();
assert.deepEqual(calls, [ assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']);
'core:{"id":"opts"}',
'set-invisible:true',
'set-initialized:true',
'warmups',
]);
}); });
test('open yomitan settings main deps map async open callbacks', async () => { test('open yomitan settings main deps map async open callbacks', async () => {

View File

@@ -45,9 +45,8 @@ export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: { export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean }; initializeOverlayRuntimeCore: (options: TOptions) => void;
buildOptions: () => TOptions; buildOptions: () => TOptions;
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void; setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void; startBackgroundWarmups: () => void;
}) { }) {
@@ -55,7 +54,6 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options), initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions(), buildOptions: () => deps.buildOptions(),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
setOverlayRuntimeInitialized: (initialized: boolean) => setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized), deps.setOverlayRuntimeInitialized(initialized),
startBackgroundWarmups: () => deps.startBackgroundWarmups(), startBackgroundWarmups: () => deps.startBackgroundWarmups(),

View File

@@ -18,9 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true, isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'), initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'), copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`), startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
mineSentenceCard: async () => { mineSentenceCard: async () => {

View File

@@ -15,9 +15,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>; mineSentenceCard: () => Promise<void>;
@@ -60,9 +58,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized, isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay, initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay, setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle, copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy, startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard, mineSentenceCard: deps.mineSentenceCard,

View File

@@ -20,9 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'), copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {}, mineSentenceCard: async () => {},
@@ -73,16 +71,8 @@ test('cli command context factory composes main deps and context handlers', () =
context.setSocketPath('/tmp/new.sock'); context.setSocketPath('/tmp/new.sock');
context.showOsd('hello'); context.showOsd('hello');
context.setVisibleOverlay(true); context.setVisibleOverlay(true);
context.setInvisibleOverlay(false);
context.toggleVisibleOverlay(); context.toggleVisibleOverlay();
context.toggleInvisibleOverlay();
assert.equal(appState.mpvSocketPath, '/tmp/new.sock'); assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
assert.deepEqual(calls, [ assert.deepEqual(calls, ['osd:hello', 'set-visible:true', 'toggle-visible']);
'osd:hello',
'set-visible:true',
'set-invisible:false',
'toggle-visible',
'toggle-invisible',
]);
}); });

View File

@@ -23,9 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'), copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
@@ -103,16 +101,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
deps.showOsd('hello'); deps.showOsd('hello');
deps.initializeOverlay(); deps.initializeOverlay();
deps.setVisibleOverlay(true); deps.setVisibleOverlay(true);
deps.setInvisibleOverlay(false);
deps.printHelp(); deps.printHelp();
assert.deepEqual(calls, [ assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
'osd:hello',
'init-overlay',
'set-visible:true',
'set-invisible:false',
'help',
]);
const retry = await deps.retryAnilistQueueNow(); const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' }); assert.deepEqual(retry, { ok: true, message: 'ok' });

View File

@@ -18,9 +18,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
@@ -70,9 +68,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(), initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => deps.mineSentenceCard(), mineSentenceCard: () => deps.mineSentenceCard(),

View File

@@ -24,9 +24,7 @@ function createDeps() {
isOverlayInitialized: () => true, isOverlayInitialized: () => true,
initializeOverlay: () => {}, initializeOverlay: () => {},
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
setVisibleOverlay: () => {}, setVisibleOverlay: () => {},
setInvisibleOverlay: () => {},
copyCurrentSubtitle: () => {}, copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {}, startPendingMultiCopy: () => {},
mineSentenceCard: async () => {}, mineSentenceCard: async () => {},

View File

@@ -20,9 +20,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>; mineSentenceCard: () => Promise<void>;
@@ -72,9 +70,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized, isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay, initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay, setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle, copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy, startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard, mineSentenceCard: deps.mineSentenceCard,

View File

@@ -32,10 +32,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
showMpvOsd: () => {}, showMpvOsd: () => {},
}, },
mainDeps: { mainDeps: {
getInvisibleWindow: () => null,
getMainWindow: () => null, getMainWindow: () => null,
getVisibleOverlayVisibility: () => false, getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
focusMainWindow: () => {}, focusMainWindow: () => {},
onOverlayModalClosed: () => {}, onOverlayModalClosed: () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
@@ -44,7 +42,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '', getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => ({}) as never,
getSubtitlePosition: () => ({}) as never, getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never, getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
@@ -56,7 +53,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnkiConnectStatus: () => false, getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [], getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {}, reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetup: () => {}, openAnilistSetup: () => {},

View File

@@ -68,12 +68,14 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},
quitApp: () => {}, quitApp: () => {},
reportJellyfinRemoteStopped: () => {}, reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {}, maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {}, logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {}, broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {}, onSubtitleChange: () => {},
refreshDiscordPresence: () => {}, refreshDiscordPresence: () => {},
updateCurrentMediaPath: () => {}, updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null, getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {}, resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {}, maybeProbeAnilistDuration: () => {},

View File

@@ -14,7 +14,6 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
getConfiguredShortcuts: () => ({}) as never, getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {}, registerGlobalShortcutsCore: () => {},
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
isDev: false, isDev: false,
getMainWindow: () => null, getMainWindow: () => null,

View File

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

View File

@@ -1,7 +1,6 @@
import type { RuntimeOptionsManager } from '../../runtime-options'; import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types'; import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import { import {
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore, getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore, getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore, isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
@@ -14,14 +13,12 @@ import {
export type ConfigDerivedRuntimeDeps = { export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig; getResolvedConfig: () => ResolvedConfig;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null; getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
platform: NodeJS.Platform;
defaultJimakuLanguagePreference: JimakuLanguagePreference; defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number; defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string; defaultJimakuApiBaseUrl: string;
}; };
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): { export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
getInitialInvisibleOverlayVisibility: () => boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isAutoUpdateEnabledRuntime: () => boolean; isAutoUpdateEnabledRuntime: () => boolean;
@@ -34,8 +31,6 @@ export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
) => Promise<JimakuApiResponse<T>>; ) => Promise<JimakuApiResponse<T>>;
} { } {
return { return {
getInitialInvisibleOverlayVisibility: () =>
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
shouldAutoInitializeOverlayRuntimeFromConfig: () => shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()), shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility: () =>

View File

@@ -15,9 +15,7 @@ test('field grouping overlay main deps builder maps window visibility and resolv
}, },
}), }),
getVisibleOverlayVisible: () => true, getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`invisible:${visible}`),
getResolver: () => resolver, getResolver: () => resolver,
setResolver: (nextResolver) => { setResolver: (nextResolver) => {
calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`); calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`);
@@ -31,17 +29,10 @@ test('field grouping overlay main deps builder maps window visibility and resolv
assert.equal(deps.getMainWindow()?.isDestroyed(), false); assert.equal(deps.getMainWindow()?.isDestroyed(), false);
assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getResolver(), resolver); assert.equal(deps.getResolver(), resolver);
assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet); assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet);
deps.setVisibleOverlayVisible(true); deps.setVisibleOverlayVisible(true);
deps.setInvisibleOverlayVisible(false);
deps.setResolver(null); deps.setResolver(null);
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true); assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, [ assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
'visible:true',
'invisible:false',
'set-resolver:null',
'send:kiku:open:1',
]);
}); });

View File

@@ -24,9 +24,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
return (): BuiltFieldGroupingOverlayMainDeps<TModal> => ({ return (): BuiltFieldGroupingOverlayMainDeps<TModal> => ({
getMainWindow: () => deps.getMainWindow(), getMainWindow: () => deps.getMainWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
getResolver: () => deps.getResolver(), getResolver: () => deps.getResolver(),
setResolver: (resolver) => deps.setResolver(resolver), setResolver: (resolver) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(), getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),

View File

@@ -28,7 +28,6 @@ test('register global shortcuts main deps map callbacks and flags', () => {
getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never), getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never),
registerGlobalShortcutsCore: () => calls.push('register'), registerGlobalShortcutsCore: () => calls.push('register'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
openYomitanSettings: () => calls.push('open-yomitan'), openYomitanSettings: () => calls.push('open-yomitan'),
isDev: true, isDev: true,
getMainWindow: () => mainWindow as never, getMainWindow: () => mainWindow as never,
@@ -38,17 +37,15 @@ test('register global shortcuts main deps map callbacks and flags', () => {
deps.registerGlobalShortcutsCore({ deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(), shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: () => undefined, onToggleVisibleOverlay: () => undefined,
onToggleInvisibleOverlay: () => undefined,
onOpenYomitanSettings: () => undefined, onOpenYomitanSettings: () => undefined,
isDev: deps.isDev, isDev: deps.isDev,
getMainWindow: deps.getMainWindow, getMainWindow: deps.getMainWindow,
}); });
deps.onToggleVisibleOverlay(); deps.onToggleVisibleOverlay();
deps.onToggleInvisibleOverlay();
deps.onOpenYomitanSettings(); deps.onOpenYomitanSettings();
assert.equal(deps.isDev, true); assert.equal(deps.isDev, true);
assert.deepEqual(deps.getMainWindow(), mainWindow); assert.deepEqual(deps.getMainWindow(), mainWindow);
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']); assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
}); });
test('refresh global shortcuts main deps map passthrough handlers', () => { test('refresh global shortcuts main deps map passthrough handlers', () => {

View File

@@ -19,7 +19,6 @@ export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts']; getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void; registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
isDev: boolean; isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow']; getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
@@ -29,7 +28,6 @@ export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) =>
deps.registerGlobalShortcutsCore(options), deps.registerGlobalShortcutsCore(options),
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(), onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
onOpenYomitanSettings: () => deps.openYomitanSettings(), onOpenYomitanSettings: () => deps.openYomitanSettings(),
isDev: deps.isDev, isDev: deps.isDev,
getMainWindow: deps.getMainWindow, getMainWindow: deps.getMainWindow,

View File

@@ -6,7 +6,6 @@ import { createGlobalShortcutsRuntimeHandlers } from './global-shortcuts-runtime
function createShortcuts(): ConfiguredShortcuts { function createShortcuts(): ConfiguredShortcuts {
return { return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O', toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's', copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s', copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c', updateLastCardFromClipboard: 'c',
@@ -38,7 +37,6 @@ test('global shortcuts runtime handlers compose get/register/refresh flow', () =
assert.equal(options.shortcuts, shortcuts); assert.equal(options.shortcuts, shortcuts);
}, },
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
openYomitanSettings: () => calls.push('open-yomitan'), openYomitanSettings: () => calls.push('open-yomitan'),
isDev: false, isDev: false,
getMainWindow: () => null, getMainWindow: () => null,

View File

@@ -10,7 +10,6 @@ import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
function createShortcuts(): ConfiguredShortcuts { function createShortcuts(): ConfiguredShortcuts {
return { return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O', toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's', copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s', copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c', updateLastCardFromClipboard: 'c',
@@ -58,18 +57,16 @@ test('register global shortcuts handler passes through callbacks and shortcuts',
assert.equal(options.isDev, true); assert.equal(options.isDev, true);
assert.equal(options.getMainWindow(), mainWindow); assert.equal(options.getMainWindow(), mainWindow);
options.onToggleVisibleOverlay(); options.onToggleVisibleOverlay();
options.onToggleInvisibleOverlay();
options.onOpenYomitanSettings(); options.onOpenYomitanSettings();
}, },
onToggleVisibleOverlay: () => calls.push('toggle-visible'), onToggleVisibleOverlay: () => calls.push('toggle-visible'),
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
onOpenYomitanSettings: () => calls.push('open-yomitan'), onOpenYomitanSettings: () => calls.push('open-yomitan'),
isDev: true, isDev: true,
getMainWindow: () => mainWindow, getMainWindow: () => mainWindow,
}); });
registerGlobalShortcuts(); registerGlobalShortcuts();
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']); assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
}); });
test('refresh global and overlay shortcuts unregisters then re-registers', () => { test('refresh global and overlay shortcuts unregisters then re-registers', () => {

View File

@@ -18,7 +18,6 @@ export function createRegisterGlobalShortcutsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts']; getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void; registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
onToggleVisibleOverlay: () => void; onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void; onOpenYomitanSettings: () => void;
isDev: boolean; isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow']; getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
@@ -27,7 +26,6 @@ export function createRegisterGlobalShortcutsHandler(deps: {
deps.registerGlobalShortcutsCore({ deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(), shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: deps.onToggleVisibleOverlay, onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
onOpenYomitanSettings: deps.onOpenYomitanSettings, onOpenYomitanSettings: deps.onOpenYomitanSettings,
isDev: deps.isDev, isDev: deps.isDev,
getMainWindow: deps.getMainWindow, getMainWindow: deps.getMainWindow,

View File

@@ -24,9 +24,6 @@ function createArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleOverlay: false, toggleOverlay: false,
hideOverlay: false, hideOverlay: false,
showOverlay: false, showOverlay: false,
toggleInvisibleOverlay: false,
hideInvisibleOverlay: false,
showInvisibleOverlay: false,
copyCurrentSubtitle: false, copyCurrentSubtitle: false,
multiCopy: false, multiCopy: false,
mineSentence: false, mineSentence: false,

View File

@@ -11,6 +11,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
const handler = createHandleMpvConnectionChangeHandler({ const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'), reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'), refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true, hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true, isQuitOnDisconnectArmed: () => true,
@@ -26,6 +27,27 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']); assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
}); });
test('mpv connection handler syncs overlay subtitle suppression on connect', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: () => {
calls.push('schedule');
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: true });
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => { test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({ const handler = createHandleMpvSubtitleTimingHandler({

View File

@@ -18,6 +18,7 @@ type MpvEventClient = {
export function createHandleMpvConnectionChangeHandler(deps: { export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean; hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean; isQuitOnDisconnectArmed: () => boolean;
@@ -27,7 +28,10 @@ export function createHandleMpvConnectionChangeHandler(deps: {
}) { }) {
return ({ connected }: { connected: boolean }): void => { return ({ connected }: { connected: boolean }): void => {
deps.refreshDiscordPresence(); deps.refreshDiscordPresence();
if (connected) return; if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
return;
}
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return; if (!deps.hasInitialJellyfinPlayArg()) return;
if (deps.isOverlayRuntimeInitialized()) return; if (deps.isOverlayRuntimeInitialized()) return;

Some files were not shown because too many files have changed in this diff Show More