mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Compare commits
21 Commits
v0.1.1
...
refactor-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
dde51f8634
|
|||
|
77c698e00b
|
|||
|
edca554db1
|
|||
|
edcd5cddb6
|
|||
|
a2551016cd
|
|||
|
3e9db1f125
|
|||
|
bc6f581ea5
|
|||
|
d4805395fa
|
|||
|
10a92f100a
|
|||
|
a03388a38f
|
|||
|
75442a4648
|
|||
|
74554a30f0
|
|||
|
643f8eb958
|
|||
|
a14c9da139
|
|||
|
ad97948062
|
|||
|
efaf9a78cd
|
|||
|
058d359553
|
|||
|
6eda768261
|
|||
|
ceea10cba1
|
|||
|
9d73971f3b
|
|||
|
a2735eaedc
|
10
Makefile
10
Makefile
@@ -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
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -53,6 +53,8 @@ help:
|
||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
||||
" dev-start Build and launch local Electron app" \
|
||||
" 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-stop Stop a running local Electron app" \
|
||||
" docs-dev Run VitePress docs dev server" \
|
||||
@@ -173,6 +175,12 @@ dev-start-macos: ensure-bun
|
||||
@bun run build
|
||||
@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
|
||||
@bun run electron . --toggle
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ subminer app --start --yomitan
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
@@ -2,9 +2,11 @@ project_name: "SubMiner"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
definition_of_done: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
auto_open_browser: true
|
||||
default_editor: "nvim"
|
||||
auto_open_browser: false
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
auto_commit: false
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
||||
// 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
|
||||
@@ -53,7 +53,6 @@
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
@@ -68,16 +67,6 @@
|
||||
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
// Invisible Overlay
|
||||
// Startup behavior for the invisible interactive subtitle mining layer.
|
||||
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
|
||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
||||
// ==========================================
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
||||
|
||||
// ==========================================
|
||||
// Keybindings (MPV Commands)
|
||||
// Extra keybindings that are merged with built-in defaults.
|
||||
@@ -125,13 +114,21 @@
|
||||
"subtitleStyle": {
|
||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||
"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.
|
||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||
"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.
|
||||
"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.
|
||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||
"knownWordColor": "#a6da95", // Known word color setting.
|
||||
"jlptColors": {
|
||||
@@ -156,12 +153,19 @@
|
||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||
}, // Frequency dictionary setting.
|
||||
"secondary": {
|
||||
"fontFamily": "Manrope, Inter", // Font family 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.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"fontWeight": "normal", // Font weight 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.
|
||||
"fontStyle": "normal" // Font style setting.
|
||||
} // Secondary setting.
|
||||
}, // Primary and secondary subtitle styling.
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ export default {
|
||||
],
|
||||
appearance: 'dark',
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
sitemap: { hostname: 'https://docs.subminer.moe' },
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**'],
|
||||
markdown: {
|
||||
@@ -94,6 +96,18 @@ export default {
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
footer: {
|
||||
message: 'Released under the GPL-3.0 License.',
|
||||
copyright: 'Copyright © 2026-present sudacode',
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/ksyasuda/SubMiner/edit/main/docs/:path',
|
||||
text: 'Edit this page on GitHub',
|
||||
},
|
||||
outline: { level: [2, 3], label: 'On this page' },
|
||||
externalLinkIcon: true,
|
||||
docFooter: { prev: 'Previous', next: 'Next' },
|
||||
returnToTopLabel: 'Back to top',
|
||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ make docs-preview # Preview built site at http://localhost:4173
|
||||
|
||||
- [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
|
||||
- [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
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ src/
|
||||
|
||||
### Service Layer (`src/core/services/`)
|
||||
|
||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-window-geometry.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts`
|
||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts`
|
||||
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
||||
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
||||
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
||||
@@ -102,7 +102,6 @@ src/renderer/
|
||||
positioning.ts # Facade export for positioning controller
|
||||
positioning/
|
||||
controller.ts # Position controller orchestration
|
||||
invisible-layout*.ts # Invisible layer layout computations
|
||||
position-state.ts # Position state helpers
|
||||
handlers/
|
||||
keyboard.ts # Keybindings, chord handling, modal key routing
|
||||
@@ -125,7 +124,7 @@ src/renderer/
|
||||
|
||||
## Flow Diagram
|
||||
|
||||
The main process has three layers: `main.ts` delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
||||
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -162,7 +161,7 @@ flowchart LR
|
||||
|
||||
subgraph Svc["Services — src/core/services/"]
|
||||
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
|
||||
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc
|
||||
Overlay["Overlay Manager<br/>window · visibility · bridge"]:::svc
|
||||
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
|
||||
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
|
||||
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
|
||||
@@ -172,9 +171,7 @@ flowchart LR
|
||||
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
|
||||
|
||||
subgraph Rend["Renderer — src/renderer/"]
|
||||
Visible["Visible window<br/>Yomitan lookups"]:::rend
|
||||
Invisible["Invisible window<br/>mpv positioning"]:::rend
|
||||
Secondary["Secondary window<br/>subtitle bar"]:::rend
|
||||
Overlay["Main overlay window<br/>primary + secondary subtitles"]:::rend
|
||||
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
|
||||
end
|
||||
|
||||
@@ -193,10 +190,8 @@ flowchart LR
|
||||
DiscordExt <-->|"RPC"| Integrations
|
||||
|
||||
Overlay & Mining --> Bridge
|
||||
Bridge --> Visible
|
||||
Bridge --> Invisible
|
||||
Bridge --> Secondary
|
||||
Visible & Invisible & Secondary --> UI
|
||||
Bridge --> Overlay
|
||||
Overlay --> UI
|
||||
|
||||
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||
@@ -264,9 +259,9 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates three overlay windows — **visible** (interactive Yomitan lookups), **invisible** (mpv-matched subtitle positioning), and **secondary** (secondary subtitle bar, top 20% via `splitOverlayGeometryForSecondaryBar`) — then registers global shortcuts and sets initial bounds from the window tracker.
|
||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering) and registers global shortcuts and bounds tracking via the active window tracker.
|
||||
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh.
|
||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results broadcast to all overlay windows.
|
||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
|
||||
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state.
|
||||
|
||||
```mermaid
|
||||
@@ -298,14 +293,10 @@ flowchart LR
|
||||
|
||||
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
|
||||
|
||||
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init
|
||||
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
|
||||
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
|
||||
OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
|
||||
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
|
||||
|
||||
VisWin --> Warmups
|
||||
InvWin --> Warmups
|
||||
SecWin --> Warmups
|
||||
MainWin --> Warmups
|
||||
Shortcuts --> Warmups
|
||||
|
||||
Warmups["Background<br/>warmups"]:::phase
|
||||
@@ -330,7 +321,7 @@ flowchart LR
|
||||
ExtEvt["Shortcuts · config hot-reload"]:::runtime
|
||||
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
|
||||
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
|
||||
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime
|
||||
Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
|
||||
end
|
||||
|
||||
WarmupGroup --> Loop
|
||||
|
||||
@@ -74,7 +74,7 @@ The configuration file includes several main sections:
|
||||
- [**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
|
||||
- [**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
|
||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||
- [**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`) |
|
||||
|
||||
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
|
||||
|
||||
@@ -379,20 +379,12 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
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.
|
||||
|
||||
- `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:
|
||||
Subtitle positioning can be adjusted directly in the overlay:
|
||||
|
||||
- `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.
|
||||
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
|
||||
|
||||
@@ -450,6 +442,8 @@ Setup flow details:
|
||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||
3. Approve access in AniList.
|
||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
||||
- Encryption backend: Linux defaults to `gnome-libsecret`.
|
||||
Override with `--password-store=<backend>` (for example `--password-store=basic_text`).
|
||||
|
||||
Token + detection notes:
|
||||
|
||||
@@ -506,6 +500,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||
|
||||
Launcher subcommands:
|
||||
|
||||
@@ -514,6 +509,7 @@ Launcher subcommands:
|
||||
- `subminer jellyfin --logout` clears stored credentials.
|
||||
- `subminer jellyfin -p` opens play picker.
|
||||
- `subminer jellyfin -d` starts cast discovery mode.
|
||||
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
|
||||
|
||||
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
||||
|
||||
@@ -666,7 +662,6 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
{
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||
"copySubtitle": "CommandOrControl+C",
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V",
|
||||
@@ -685,7 +680,6 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| Option | Values | Description |
|
||||
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
@@ -730,15 +724,23 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
```json
|
||||
{
|
||||
"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,
|
||||
"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",
|
||||
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
||||
"backdropFilter": "blur(6px)",
|
||||
"secondary": {
|
||||
"fontFamily": "Manrope, Inter",
|
||||
"fontSize": 24,
|
||||
"fontColor": "#ffffff",
|
||||
"fontColor": "#cad3f5",
|
||||
"backgroundColor": "transparent"
|
||||
}
|
||||
}
|
||||
@@ -747,10 +749,10 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
|
||||
| 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`) |
|
||||
| `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"`) |
|
||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
@@ -778,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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 . --background # tray/background mode, minimal default logging
|
||||
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
|
||||
|
||||
@@ -38,8 +38,8 @@ features:
|
||||
- icon:
|
||||
src: /assets/dual-layer.svg
|
||||
alt: Dual layer icon
|
||||
title: Three-Plane Overlay Stack
|
||||
details: Secondary context plane + visible interactive layer + invisible interaction plane, each with independent behavior and startup state.
|
||||
title: Unified Overlay Stack
|
||||
details: Primary interactive subtitle layer with a built-in secondary context bar, all in one overlay window.
|
||||
- icon:
|
||||
src: /assets/highlight.svg
|
||||
alt: Highlight icon
|
||||
|
||||
@@ -181,9 +181,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop 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-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
|
||||
@@ -12,6 +12,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
||||
|
||||
- Jellyfin server URL and user credentials
|
||||
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
||||
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -150,6 +151,7 @@ User-visible errors are shown through CLI logs and mpv OSD for:
|
||||
## Security Notes and Limitations
|
||||
|
||||
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
||||
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
|
||||
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
||||
- Treat both token storage and config files as secrets and avoid committing them.
|
||||
- Password is used only for login and is not stored.
|
||||
|
||||
@@ -24,11 +24,11 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization:
|
||||
|
||||
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
||||
|
||||
## The Three Overlay Planes
|
||||
## Overlay Model
|
||||
|
||||
SubMiner uses three overlay planes, each serving a different purpose.
|
||||
SubMiner uses one overlay window with modal surfaces.
|
||||
|
||||
### Visible Overlay
|
||||
### Primary Subtitle Layer
|
||||
|
||||
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||
|
||||
@@ -38,31 +38,17 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w
|
||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||
- **N+1 highlighting** — known words from your Anki deck are visually highlighted
|
||||
|
||||
Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
|
||||
### Secondary Subtitle Plane
|
||||
### Secondary Subtitle Bar
|
||||
|
||||
The secondary plane is a compact top-strip layer for translation and context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
||||
The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
||||
|
||||
It is controlled by `secondarySub` configuration and shares lifecycle with the overlay stack.
|
||||
It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
|
||||
|
||||
### Invisible Overlay
|
||||
### Modal Surfaces
|
||||
|
||||
The invisible overlay is a transparent layer aligned with mpv's own subtitle rendering. It uses mpv's subtitle metrics (font size, margins, position, scaling) to map click targets accurately.
|
||||
|
||||
This layer still supports:
|
||||
|
||||
- Word-level click-through lookups over the text region
|
||||
- Optional manual position fine-tuning in pixel mode
|
||||
- Independent toggle behavior with global shortcuts
|
||||
|
||||
Position edit mode is available via `Ctrl/Cmd+Shift+P`, then arrow keys / `hjkl` to nudge position; `Shift` moves faster. Save with `Enter` or `Ctrl+S`, cancel with `Esc`.
|
||||
|
||||
Toggle controls:
|
||||
|
||||
- `Alt+Shift+O` / `y-t`: visible overlay
|
||||
- `Alt+Shift+I` / `y-i`: invisible overlay
|
||||
- Secondary plane visibility is controlled via `secondarySub` config and matching global shortcuts.
|
||||
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||
|
||||
## Looking Up Words
|
||||
|
||||
@@ -73,10 +59,10 @@ Toggle controls:
|
||||
3. Yomitan detects the text selection and opens its popup with dictionary results.
|
||||
4. From the Yomitan popup, you can add the word directly to Anki.
|
||||
|
||||
### On the Invisible Overlay
|
||||
### On Overlay Subtitles
|
||||
|
||||
1. The invisible layer sits over mpv's own subtitle text.
|
||||
2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text.
|
||||
1. Subtitles are rendered directly in the overlay.
|
||||
2. Click on any word in the subtitle.
|
||||
3. On macOS, word selection happens automatically on hover.
|
||||
4. Yomitan popup appears for lookup and card creation.
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop 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-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
@@ -50,10 +47,9 @@ SubMiner:
|
||||
1. Start overlay
|
||||
2. Stop overlay
|
||||
3. Toggle overlay
|
||||
4. Toggle invisible overlay
|
||||
5. Open options
|
||||
6. Restart overlay
|
||||
7. Check status
|
||||
4. Open options
|
||||
5. Restart overlay
|
||||
6. Check status
|
||||
```
|
||||
|
||||
Select an item by pressing its number.
|
||||
@@ -84,10 +80,6 @@ auto_start=no
|
||||
# Show the visible overlay on auto-start.
|
||||
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.
|
||||
osd_messages=yes
|
||||
|
||||
@@ -129,7 +121,6 @@ aniskip_button_duration=3
|
||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||
| `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_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
|
||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||
| `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-stop
|
||||
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-options
|
||||
script-message subminer-restart
|
||||
|
||||
55
docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
Normal file
55
docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Secondary Subtitles Main Overlay Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ensure secondary subtitles render in the unified main overlay window and remove stale secondary-window/layer paths.
|
||||
|
||||
**Architecture:** Keep secondary subtitle DOM in the shared renderer tree, rely on mode classes (`secondary-sub-hidden|visible|hover`) for visibility, and remove obsolete legacy overlay-layer assumptions. Preserve modal behavior and existing subtitle rendering flow.
|
||||
|
||||
**Tech Stack:** TypeScript, Electron renderer CSS/DOM, Bun test runner.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/subtitle-render.test.ts`
|
||||
- Modify: `src/renderer/error-recovery.test.ts`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
|
||||
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
|
||||
|
||||
**Step 2: Run tests to verify failures**
|
||||
|
||||
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
||||
Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling.
|
||||
|
||||
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/style.css`
|
||||
- Modify: `src/renderer/utils/platform.ts`
|
||||
- Modify: `src/renderer/error-recovery.ts`
|
||||
- Modify: `src/types.ts`
|
||||
|
||||
**Step 1: Implement minimal changes**
|
||||
- Remove legacy forced hide on `#secondarySubContainer`.
|
||||
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
|
||||
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
|
||||
- Narrow related overlay layer type unions.
|
||||
|
||||
**Step 2: Run targeted tests**
|
||||
|
||||
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Validate Wider Related Surface
|
||||
|
||||
**Files:**
|
||||
- No additional code changes required.
|
||||
|
||||
**Step 1: Run broader related tests**
|
||||
|
||||
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/overlay-window-factory.test.ts src/core/services/overlay-manager.test.ts`
|
||||
Expected: Renderer tests pass; report any unrelated pre-existing failures.
|
||||
@@ -17,7 +17,7 @@
|
||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
||||
// 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
|
||||
@@ -53,7 +53,6 @@
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
@@ -68,16 +67,6 @@
|
||||
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
// Invisible Overlay
|
||||
// Startup behavior for the invisible interactive subtitle mining layer.
|
||||
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
|
||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
||||
// ==========================================
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
||||
|
||||
// ==========================================
|
||||
// Keybindings (MPV Commands)
|
||||
// Extra keybindings that are merged with built-in defaults.
|
||||
@@ -125,13 +114,21 @@
|
||||
"subtitleStyle": {
|
||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||
"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.
|
||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||
"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.
|
||||
"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.
|
||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||
"knownWordColor": "#a6da95", // Known word color setting.
|
||||
"jlptColors": {
|
||||
@@ -156,12 +153,19 @@
|
||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||
}, // Frequency dictionary setting.
|
||||
"secondary": {
|
||||
"fontFamily": "Manrope, Inter", // Font family 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.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"fontWeight": "normal", // Font weight 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.
|
||||
"fontStyle": "normal" // Font style setting.
|
||||
} // Secondary setting.
|
||||
}, // Primary and secondary subtitle styling.
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ These work system-wide regardless of which window has focus.
|
||||
| Shortcut | Action | Configurable |
|
||||
| ------------- | ------------------------ | ---------------------------------------- |
|
||||
| `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) |
|
||||
|
||||
::: 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+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 |
|
||||
| --------------------- | -------------------------------- |
|
||||
@@ -86,9 +85,6 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop 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-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
@@ -112,7 +108,6 @@ All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electro
|
||||
"mineSentence": "CommandOrControl+S",
|
||||
"copySubtitle": "CommandOrControl+C",
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||
"openJimaku": null, // disabled
|
||||
},
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
|
||||
- Sway: Ensure `swaymsg` is available.
|
||||
- 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
|
||||
|
||||
@@ -217,10 +217,10 @@ Media generation has a 30-second timeout (60 seconds for animated AVIF). If your
|
||||
|
||||
**"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.
|
||||
- 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.
|
||||
|
||||
**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
|
||||
|
||||
- **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`
|
||||
|
||||
@@ -5,7 +5,7 @@ There are two ways to use SubMiner — the `subminer` wrapper script or the mpv
|
||||
| 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`). |
|
||||
| **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.
|
||||
|
||||
@@ -68,11 +68,8 @@ SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||
SubMiner.AppImage --stop # Stop overlay
|
||||
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 --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 --debug # Alias for --dev
|
||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||
@@ -94,6 +91,9 @@ SubMiner.AppImage --help # Show all options
|
||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||
Override with e.g. `--password-store=basic_text`.
|
||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
||||
|
||||
### Launcher Subcommands
|
||||
@@ -170,7 +170,6 @@ Notes:
|
||||
| Keybind | Action |
|
||||
| ------------- | ------------------------ |
|
||||
| `Alt+Shift+O` | Toggle visible overlay |
|
||||
| `Alt+Shift+I` | Toggle invisible overlay |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings |
|
||||
|
||||
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
|
||||
@@ -192,10 +191,10 @@ Notes:
|
||||
| `Ctrl+W` | Quit mpv |
|
||||
| `Right-click` | Toggle MPV pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Move subtitle position (on subtitle) |
|
||||
| `Ctrl/Cmd+Shift+P` | Toggle invisible subtitle position edit mode |
|
||||
| `Arrow keys` | Move invisible subtitles while edit mode is active |
|
||||
| `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode |
|
||||
| `Esc` | Cancel invisible subtitle position edit mode |
|
||||
| `Ctrl/Cmd+Shift+P` | Toggle subtitle position edit mode |
|
||||
| `Arrow keys` | Move subtitles while edit mode is active |
|
||||
| `Enter` / `Ctrl+S` | Save subtitle position in edit mode |
|
||||
| `Esc` | Cancel subtitle position edit mode |
|
||||
| `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.
|
||||
|
||||
@@ -10,9 +10,16 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
return false;
|
||||
}
|
||||
|
||||
const appendPasswordStore = (forwarded: string[]): void => {
|
||||
if (args.passwordStore) {
|
||||
forwarded.push('--password-store', args.passwordStore);
|
||||
}
|
||||
};
|
||||
|
||||
if (args.jellyfin) {
|
||||
const forwarded = ['--jellyfin'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
@@ -35,12 +42,14 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
password,
|
||||
];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
if (args.jellyfinLogout) {
|
||||
const forwarded = ['--jellyfin-logout'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
@@ -58,6 +67,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.jellyfinDiscovery) {
|
||||
const forwarded = ['--start'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
texthookerOnly: false,
|
||||
useRofi: false,
|
||||
logLevel: 'info',
|
||||
passwordStore: '',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
};
|
||||
@@ -161,6 +162,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||
if (options.start === true) parsed.startOverlay = true;
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
@@ -175,6 +177,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.jellyfinInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
||||
}
|
||||
if (typeof invocations.jellyfinInvocation.passwordStore === 'string') {
|
||||
parsed.passwordStore = invocations.jellyfinInvocation.passwordStore;
|
||||
}
|
||||
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
||||
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
||||
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface JellyfinInvocation {
|
||||
server?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
passwordStore?: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
@@ -168,6 +169,7 @@ export function parseCliPrograms(
|
||||
.option('-s, --server <url>', 'Jellyfin server URL')
|
||||
.option('-u, --username <name>', 'Jellyfin username')
|
||||
.option('-w, --password <pass>', 'Jellyfin password')
|
||||
.option('--password-store <backend>', 'Pass through Electron safeStorage backend')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
jellyfinInvocation = {
|
||||
@@ -180,6 +182,7 @@ export function parseCliPrograms(
|
||||
server: typeof options.server === 'string' ? options.server : undefined,
|
||||
username: typeof options.username === 'string' ? options.username : undefined,
|
||||
password: typeof options.password === 'string' ? options.password : undefined,
|
||||
passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -395,5 +395,6 @@ export async function runJellyfinPlayMenu(
|
||||
}
|
||||
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
}
|
||||
|
||||
@@ -207,3 +207,33 @@ test('jellyfin login routes credentials to app command', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin setup forwards password-store to app command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(
|
||||
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||
env,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
'--jellyfin\n--password-store\ngnome-libsecret\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,17 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs forwards jellyfin password-store option', () => {
|
||||
const parsed = parseArgs(
|
||||
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||
'subminer',
|
||||
{},
|
||||
);
|
||||
|
||||
assert.equal(parsed.jellyfin, true);
|
||||
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
||||
});
|
||||
|
||||
test('parseArgs maps config show action', () => {
|
||||
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Args {
|
||||
texthookerOnly: boolean;
|
||||
useRofi: boolean;
|
||||
logLevel: LogLevel;
|
||||
passwordStore: string;
|
||||
target: string;
|
||||
targetKind: '' | 'file' | 'url';
|
||||
jimakuApiKey: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -21,8 +21,8 @@
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/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:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||
|
||||
@@ -21,18 +21,10 @@ texthooker_port=5174
|
||||
backend=auto
|
||||
|
||||
# Automatically start overlay when a file is loaded
|
||||
auto_start=no
|
||||
auto_start=yes
|
||||
|
||||
# Automatically show visible overlay when overlay starts
|
||||
auto_start_visible_overlay=no
|
||||
|
||||
# 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
|
||||
auto_start_visible_overlay=yes
|
||||
|
||||
# Show OSD messages for overlay status
|
||||
osd_messages=yes
|
||||
@@ -70,4 +62,3 @@ aniskip_button_duration=3
|
||||
|
||||
# MPV keybindings provided by plugin/subminer.lua:
|
||||
# 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
|
||||
|
||||
@@ -24,10 +24,6 @@ local function default_socket_path()
|
||||
return "/tmp/subminer-socket"
|
||||
end
|
||||
|
||||
local function is_linux()
|
||||
return not is_windows() and not is_macos()
|
||||
end
|
||||
|
||||
local function is_subminer_process_running()
|
||||
local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" }
|
||||
local result = mp.command_native({
|
||||
@@ -138,9 +134,7 @@ local opts = {
|
||||
texthooker_port = 5174,
|
||||
backend = "auto",
|
||||
auto_start = true,
|
||||
auto_start_overlay = false, -- legacy alias, maps to auto_start_visible_overlay
|
||||
auto_start_visible_overlay = false,
|
||||
auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden
|
||||
osd_messages = true,
|
||||
log_level = "info",
|
||||
aniskip_enabled = true,
|
||||
@@ -163,7 +157,6 @@ local state = {
|
||||
binary_available = false,
|
||||
binary_path = nil,
|
||||
detected_backend = nil,
|
||||
invisible_overlay_visible = false,
|
||||
hover_highlight = {
|
||||
revision = -1,
|
||||
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_LEGACY = "yomipv-hover-token"
|
||||
local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
|
||||
@@ -796,6 +793,15 @@ local function fix_ass_color(input, fallback)
|
||||
return b .. g .. r
|
||||
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)
|
||||
return (text or "")
|
||||
:gsub("\\", "\\\\")
|
||||
@@ -858,7 +864,7 @@ local function resolve_metrics()
|
||||
border = sub_border_size * window_scale,
|
||||
shadow = sub_shadow_offset * window_scale,
|
||||
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
|
||||
|
||||
@@ -1068,7 +1074,7 @@ local function build_hover_subtitle_content(payload)
|
||||
end
|
||||
|
||||
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)
|
||||
return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||
end
|
||||
@@ -1435,47 +1441,48 @@ local function parse_start_script_message_overrides(...)
|
||||
end
|
||||
|
||||
local function resolve_visible_overlay_startup()
|
||||
local visible = 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()
|
||||
return coerce_bool(opts.auto_start_visible_overlay, false)
|
||||
end
|
||||
|
||||
local function apply_startup_overlay_preferences()
|
||||
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"
|
||||
if not run_control_command(visible_action) then
|
||||
subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action)
|
||||
local function try_apply(attempt)
|
||||
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
|
||||
|
||||
local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay"
|
||||
if not run_control_command(invisible_action) then
|
||||
subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action)
|
||||
end
|
||||
try_apply(1)
|
||||
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
|
||||
|
||||
local function build_texthooker_args()
|
||||
@@ -1577,7 +1584,7 @@ local function start_overlay(overrides)
|
||||
end)
|
||||
|
||||
-- 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()
|
||||
end)
|
||||
end
|
||||
@@ -1646,90 +1653,6 @@ local function toggle_overlay()
|
||||
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()
|
||||
if not state.binary_available then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
@@ -1768,7 +1691,6 @@ local function show_menu()
|
||||
"Start overlay",
|
||||
"Stop overlay",
|
||||
"Toggle overlay",
|
||||
"Toggle invisible overlay",
|
||||
"Open options",
|
||||
"Restart overlay",
|
||||
"Check status",
|
||||
@@ -1778,7 +1700,6 @@ local function show_menu()
|
||||
start_overlay,
|
||||
stop_overlay,
|
||||
toggle_overlay,
|
||||
toggle_invisible_overlay,
|
||||
open_options,
|
||||
restart_overlay,
|
||||
check_status,
|
||||
@@ -1856,28 +1777,16 @@ check_status = function()
|
||||
end
|
||||
|
||||
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()
|
||||
fetch_aniskip_for_current_media()
|
||||
state.binary_path = find_binary()
|
||||
if state.binary_path then
|
||||
state.binary_available = true
|
||||
subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")")
|
||||
local should_auto_start = coerce_bool(opts.auto_start, false)
|
||||
if should_auto_start then
|
||||
start_overlay()
|
||||
end
|
||||
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
|
||||
refresh_subminer_runtime_state()
|
||||
if not state.binary_available then
|
||||
return
|
||||
end
|
||||
|
||||
local should_auto_start = coerce_bool(opts.auto_start, false)
|
||||
if should_auto_start then
|
||||
start_overlay()
|
||||
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-stop", stop_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-o", "subminer-options", open_options)
|
||||
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-stop", stop_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-options", open_options)
|
||||
mp.register_script_message("subminer-restart", restart_overlay)
|
||||
|
||||
63
scripts/dev-watch.sh
Executable file
63
scripts/dev-watch.sh
Executable 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
8
scripts/subminer-dev.sh
Executable 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 . "$@"
|
||||
@@ -4,14 +4,11 @@ export interface CliArgs {
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
toggleInvisibleOverlay: boolean;
|
||||
settings: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
showVisibleOverlay: boolean;
|
||||
hideVisibleOverlay: boolean;
|
||||
showInvisibleOverlay: boolean;
|
||||
hideInvisibleOverlay: boolean;
|
||||
copySubtitle: boolean;
|
||||
copySubtitleMultiple: boolean;
|
||||
mineSentence: boolean;
|
||||
@@ -67,14 +64,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
@@ -122,14 +116,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--stop') args.stop = true;
|
||||
else if (arg === '--toggle') args.toggle = 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 === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = 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-multiple') args.copySubtitleMultiple = true;
|
||||
else if (arg === '--mine-sentence') args.mineSentence = true;
|
||||
@@ -263,14 +254,11 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.showInvisibleOverlay ||
|
||||
args.hideInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
@@ -307,7 +295,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.start ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
@@ -331,13 +318,10 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
return (
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.showInvisibleOverlay ||
|
||||
args.hideInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
|
||||
@@ -17,11 +17,8 @@ ${B}Session${R}
|
||||
|
||||
${B}Overlay${R}
|
||||
--toggle-visible-overlay Toggle subtitle overlay
|
||||
--toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R}
|
||||
--show-visible-overlay Show 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
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
|
||||
@@ -27,7 +27,22 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||
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.dbPath, '');
|
||||
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', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -597,19 +650,15 @@ test('warns and ignores unknown top-level config keys', () => {
|
||||
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();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||
"openJimaku": "Ctrl+Alt+J"
|
||||
},
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "hidden"
|
||||
},
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": false,
|
||||
"youtubeSubgen": {
|
||||
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||
@@ -621,9 +670,7 @@ test('parses invisible overlay config and new global shortcuts', () => {
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
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.invisibleOverlay.startupVisibility, 'hidden');
|
||||
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
|
||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ const {
|
||||
subsync,
|
||||
auto_start_overlay,
|
||||
bind_visible_overlay_to_mpv_sub_visibility,
|
||||
invisibleOverlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
@@ -54,7 +53,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
youtubeSubgen,
|
||||
invisibleOverlay,
|
||||
immersionTracking,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'subsync'
|
||||
| 'auto_start_overlay'
|
||||
| 'bind_visible_overlay_to_mpv_sub_visibility'
|
||||
| 'invisibleOverlay'
|
||||
> = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
@@ -28,7 +27,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||
toggleInvisibleOverlayGlobal: 'Alt+Shift+I',
|
||||
copySubtitle: 'CommandOrControl+C',
|
||||
copySubtitleMultiple: 'CommandOrControl+Shift+C',
|
||||
updateLastCardFromClipboard: 'CommandOrControl+V',
|
||||
@@ -55,7 +53,4 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: true,
|
||||
invisibleOverlay: {
|
||||
startupVisibility: 'platform-default',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,14 +4,21 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
subtitleStyle: {
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
hoverTokenColor: '#c6a0f6',
|
||||
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',
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
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',
|
||||
backgroundColor: 'rgb(30, 32, 48, 0.88)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
knownWordColor: '#a6da95',
|
||||
jlptColors: {
|
||||
@@ -30,13 +37,19 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Manrope, Inter',
|
||||
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',
|
||||
backdropFilter: 'blur(6px)',
|
||||
fontWeight: '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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export function buildCoreConfigOptionRegistry(
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
description:
|
||||
'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).',
|
||||
'Link visible overlay toggles to MPV primary subtitle visibility.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
|
||||
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',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -40,15 +40,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
|
||||
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)',
|
||||
description: [
|
||||
|
||||
@@ -77,7 +77,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
if (isObject(src.shortcuts)) {
|
||||
const shortcutKeys = [
|
||||
'toggleVisibleOverlayGlobal',
|
||||
'toggleInvisibleOverlayGlobal',
|
||||
'copySubtitle',
|
||||
'copySubtitleMultiple',
|
||||
'updateLastCardFromClipboard',
|
||||
@@ -113,24 +112,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.invisibleOverlay)) {
|
||||
const startupVisibility = src.invisibleOverlay.startupVisibility;
|
||||
if (
|
||||
startupVisibility === 'platform-default' ||
|
||||
startupVisibility === 'visible' ||
|
||||
startupVisibility === 'hidden'
|
||||
) {
|
||||
resolved.invisibleOverlay.startupVisibility = startupVisibility;
|
||||
} else if (startupVisibility !== undefined) {
|
||||
warn(
|
||||
'invisibleOverlay.startupVisibility',
|
||||
startupVisibility,
|
||||
resolved.invisibleOverlay.startupVisibility,
|
||||
'Expected platform-default, visible, or hidden.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.secondarySub)) {
|
||||
if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
|
||||
resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter(
|
||||
|
||||
@@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
resolved.subtitleStyle = {
|
||||
...resolved.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(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||
)
|
||||
|
||||
@@ -30,10 +30,18 @@ function createStorage(encryptionAvailable: boolean): SafeStorageLike {
|
||||
};
|
||||
}
|
||||
|
||||
function createPassthroughStorage(): SafeStorageLike {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf-8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8'),
|
||||
};
|
||||
}
|
||||
|
||||
test('anilist token store saves and loads encrypted token', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
store.saveToken(' demo-token ');
|
||||
assert.equal(store.saveToken(' demo-token '), true);
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
encryptedToken?: string;
|
||||
@@ -44,16 +52,13 @@ test('anilist token store saves and loads encrypted token', () => {
|
||||
assert.equal(store.loadToken(), 'demo-token');
|
||||
});
|
||||
|
||||
test('anilist token store falls back to plaintext when encryption unavailable', () => {
|
||||
test('anilist token store refuses to persist token when encryption unavailable', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
|
||||
store.saveToken('plain-token');
|
||||
assert.equal(store.saveToken('plain-token'), false);
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
plaintextToken?: string;
|
||||
};
|
||||
assert.equal(payload.plaintextToken, 'plain-token');
|
||||
assert.equal(store.loadToken(), 'plain-token');
|
||||
assert.equal(fs.existsSync(filePath), false);
|
||||
assert.equal(store.loadToken(), null);
|
||||
});
|
||||
|
||||
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||
@@ -75,6 +80,13 @@ test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||
assert.equal(payload.plaintextToken, undefined);
|
||||
});
|
||||
|
||||
test('anilist token store refuses passthrough safeStorage implementation', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createPassthroughStorage());
|
||||
assert.equal(store.saveToken('demo-token'), false);
|
||||
assert.equal(store.loadToken(), null);
|
||||
});
|
||||
|
||||
test('anilist token store clears persisted token file', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
|
||||
@@ -10,7 +10,7 @@ interface PersistedTokenPayload {
|
||||
|
||||
export interface AnilistTokenStore {
|
||||
loadToken: () => string | null;
|
||||
saveToken: (token: string) => void;
|
||||
saveToken: (token: string) => boolean;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SafeStorageLike {
|
||||
isEncryptionAvailable: () => boolean;
|
||||
encryptString: (value: string) => Buffer;
|
||||
decryptString: (value: Buffer) => string;
|
||||
getSelectedStorageBackend?: () => string;
|
||||
}
|
||||
|
||||
function ensureDirectory(filePath: string): void {
|
||||
@@ -38,9 +39,80 @@ export function createAnilistTokenStore(
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
warnUser?: (message: string) => void;
|
||||
},
|
||||
storage: SafeStorageLike = electron.safeStorage,
|
||||
): AnilistTokenStore {
|
||||
let safeStorageUsable: boolean | null = null;
|
||||
|
||||
const getSelectedBackend = (): string => {
|
||||
if (typeof storage.getSelectedStorageBackend !== 'function') {
|
||||
return 'unsupported';
|
||||
}
|
||||
try {
|
||||
return storage.getSelectedStorageBackend();
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
const getSafeStorageDebugContext = (): string =>
|
||||
JSON.stringify({
|
||||
platform: process.platform,
|
||||
dbusSession: process.env.DBUS_SESSION_BUS_ADDRESS,
|
||||
xdgRuntimeDir: process.env.XDG_RUNTIME_DIR,
|
||||
display: process.env.DISPLAY,
|
||||
waylandDisplay: process.env.WAYLAND_DISPLAY,
|
||||
hasDefaultApp: Boolean(process.defaultApp),
|
||||
selectedSafeStorageBackend: getSelectedBackend(),
|
||||
});
|
||||
|
||||
const isSafeStorageUsable = (): boolean => {
|
||||
if (safeStorageUsable != null) return safeStorageUsable;
|
||||
|
||||
try {
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
notifyUser(
|
||||
`AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` +
|
||||
`Context: ${getSafeStorageDebugContext()}`,
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
const probe = storage.encryptString('__subminer_anilist_probe__');
|
||||
if (probe.equals(Buffer.from('__subminer_anilist_probe__'))) {
|
||||
notifyUser(
|
||||
'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.',
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
const roundTrip = storage.decryptString(probe);
|
||||
if (roundTrip !== '__subminer_anilist_probe__') {
|
||||
notifyUser(
|
||||
'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.',
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
safeStorageUsable = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('AniList token encryption probe failed.', error);
|
||||
notifyUser(
|
||||
`AniList token encryption unavailable: safeStorage probe threw an error. ` +
|
||||
`Context: ${getSafeStorageDebugContext()}`,
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const notifyUser = (message: string): void => {
|
||||
logger.warn(message);
|
||||
logger.warnUser?.(message);
|
||||
};
|
||||
|
||||
return {
|
||||
loadToken(): string | null {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -51,18 +123,33 @@ export function createAnilistTokenStore(
|
||||
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
||||
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
||||
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
logger.warn('AniList token encryption is not available on this system.');
|
||||
if (!isSafeStorageUsable()) {
|
||||
return null;
|
||||
}
|
||||
const decrypted = storage.decryptString(encrypted).trim();
|
||||
return decrypted.length > 0 ? decrypted : null;
|
||||
if (decrypted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
|
||||
// Legacy fallback: migrate plaintext token to encrypted storage on load.
|
||||
const plaintext = parsed.plaintextToken.trim();
|
||||
this.saveToken(plaintext);
|
||||
return plaintext;
|
||||
if (
|
||||
typeof parsed.plaintextToken === 'string' &&
|
||||
parsed.plaintextToken.trim().length > 0
|
||||
) {
|
||||
if (storage.isEncryptionAvailable()) {
|
||||
if (!isSafeStorageUsable()) {
|
||||
return null;
|
||||
}
|
||||
const plaintext = parsed.plaintextToken.trim();
|
||||
notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
|
||||
this.saveToken(plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
notifyUser(
|
||||
'AniList token plaintext was found but ignored because safe storage is unavailable.',
|
||||
);
|
||||
this.clearToken();
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to read AniList token store.', error);
|
||||
@@ -70,28 +157,28 @@ export function createAnilistTokenStore(
|
||||
return null;
|
||||
},
|
||||
|
||||
saveToken(token: string): void {
|
||||
saveToken(token: string): boolean {
|
||||
const trimmed = token.trim();
|
||||
if (trimmed.length === 0) {
|
||||
this.clearToken();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
|
||||
writePayload(filePath, {
|
||||
plaintextToken: trimmed,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
if (!isSafeStorageUsable()) {
|
||||
notifyUser(
|
||||
'AniList token encryption is unavailable; refusing to store access token. Re-login required after restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const encrypted = storage.encryptString(trimmed);
|
||||
writePayload(filePath, {
|
||||
encryptedToken: encrypted.toString('base64'),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist AniList token.', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
@@ -94,18 +91,12 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
toggleVisibleOverlay: () => {
|
||||
calls.push('toggleVisibleOverlay');
|
||||
},
|
||||
toggleInvisibleOverlay: () => {
|
||||
calls.push('toggleInvisibleOverlay');
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setVisibleOverlayVisible:${visible}`);
|
||||
},
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setInvisibleOverlayVisible:${visible}`);
|
||||
},
|
||||
copyCurrentSubtitle: () => {
|
||||
calls.push('copyCurrentSubtitle');
|
||||
},
|
||||
@@ -339,10 +330,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
args: Partial<CliArgs>;
|
||||
expected: string;
|
||||
}> = [
|
||||
{
|
||||
args: { toggleInvisibleOverlay: true },
|
||||
expected: 'toggleInvisibleOverlay',
|
||||
},
|
||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||
{
|
||||
args: { showVisibleOverlay: true },
|
||||
@@ -352,14 +339,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
args: { hideVisibleOverlay: true },
|
||||
expected: 'setVisibleOverlayVisible:false',
|
||||
},
|
||||
{
|
||||
args: { showInvisibleOverlay: true },
|
||||
expected: 'setInvisibleOverlayVisible:true',
|
||||
},
|
||||
{
|
||||
args: { hideInvisibleOverlay: true },
|
||||
expected: 'setInvisibleOverlayVisible:false',
|
||||
},
|
||||
{ args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
|
||||
{
|
||||
args: { copySubtitleMultiple: true },
|
||||
|
||||
@@ -16,10 +16,8 @@ export interface CliCommandServiceDeps {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
@@ -93,9 +91,7 @@ interface OverlayCliRuntime {
|
||||
isInitialized: () => boolean;
|
||||
initialize: () => void;
|
||||
toggleVisible: () => void;
|
||||
toggleInvisible: () => void;
|
||||
setVisible: (visible: boolean) => void;
|
||||
setInvisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface MiningCliRuntime {
|
||||
@@ -180,14 +176,12 @@ export function createCliCommandDepsRuntime(
|
||||
isOverlayRuntimeInitialized: options.overlay.isInitialized,
|
||||
initializeOverlayRuntime: options.overlay.initialize,
|
||||
toggleVisibleOverlay: options.overlay.toggleVisible,
|
||||
toggleInvisibleOverlay: options.overlay.toggleInvisible,
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
setInvisibleOverlayVisible: options.overlay.setInvisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
mineSentenceCard: options.mining.mineSentenceCard,
|
||||
@@ -242,14 +236,11 @@ export function handleCliCommand(
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.showInvisibleOverlay ||
|
||||
args.hideInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
@@ -286,10 +277,7 @@ export function handleCliCommand(
|
||||
}
|
||||
|
||||
const shouldStart =
|
||||
args.start ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay;
|
||||
args.start || args.toggle || args.toggleVisibleOverlay;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
@@ -325,18 +313,12 @@ export function handleCliCommand(
|
||||
|
||||
if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.toggleInvisibleOverlay) {
|
||||
deps.toggleInvisibleOverlay();
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(false);
|
||||
} else if (args.showInvisibleOverlay) {
|
||||
deps.setInvisibleOverlayVisible(true);
|
||||
} else if (args.hideInvisibleOverlay) {
|
||||
deps.setInvisibleOverlayVisible(false);
|
||||
} else if (args.copySubtitle) {
|
||||
deps.copyCurrentSubtitle();
|
||||
} else if (args.copySubtitleMultiple) {
|
||||
|
||||
@@ -19,11 +19,9 @@ test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore
|
||||
},
|
||||
}),
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => restore,
|
||||
@@ -44,9 +42,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = next;
|
||||
@@ -87,12 +83,10 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: (nextVisible) => {
|
||||
visible = nextVisible;
|
||||
visibilityTransitions.push(nextVisible);
|
||||
},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
|
||||
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = nextResolver;
|
||||
|
||||
@@ -11,9 +11,7 @@ interface WindowLike {
|
||||
export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
|
||||
@@ -65,9 +63,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
) => Promise<KikuFieldGroupingChoice>) => {
|
||||
return createFieldGroupingCallbackRuntime({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
|
||||
@@ -2,9 +2,7 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../typ
|
||||
|
||||
export function createFieldGroupingCallback(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
|
||||
@@ -22,7 +20,6 @@ export function createFieldGroupingCallback(options: {
|
||||
}
|
||||
|
||||
const previousVisibleOverlay = options.getVisibleOverlayVisible();
|
||||
const previousInvisibleOverlay = options.getInvisibleOverlayVisible();
|
||||
let settled = false;
|
||||
|
||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
||||
@@ -36,9 +33,6 @@ export function createFieldGroupingCallback(options: {
|
||||
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||
options.setVisibleOverlayVisible(false);
|
||||
}
|
||||
if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) {
|
||||
options.setInvisibleOverlayVisible(previousInvisibleOverlay);
|
||||
}
|
||||
};
|
||||
|
||||
options.setResolver(finish);
|
||||
|
||||
@@ -23,7 +23,6 @@ export {
|
||||
export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle';
|
||||
export { cycleSecondarySubMode } from './subtitle-position';
|
||||
export {
|
||||
getInitialInvisibleOverlayVisibility,
|
||||
isAutoUpdateEnabledRuntime,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
shouldBindVisibleOverlayToMpvSubVisibility,
|
||||
@@ -59,14 +58,12 @@ export {
|
||||
createOverlayWindow,
|
||||
enforceOverlayLayerOrder,
|
||||
ensureOverlayWindowLevel,
|
||||
syncOverlayWindowLayer,
|
||||
updateOverlayWindowBounds,
|
||||
} from './overlay-window';
|
||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
export {
|
||||
setInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible,
|
||||
syncInvisibleOverlayMousePassthrough,
|
||||
updateInvisibleOverlayVisibility,
|
||||
updateVisibleOverlayVisibility,
|
||||
} from './overlay-visibility';
|
||||
export {
|
||||
@@ -76,6 +73,7 @@ export {
|
||||
replayCurrentSubtitleRuntime,
|
||||
resolveCurrentAudioStreamIndex,
|
||||
sendMpvCommandRuntime,
|
||||
setMpvSecondarySubVisibilityRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
showMpvOsdRuntime,
|
||||
} from './mpv';
|
||||
|
||||
@@ -36,10 +36,8 @@ function createFakeIpcRegistrar(): {
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
getInvisibleWindow: () => null,
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
@@ -47,7 +45,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
@@ -64,7 +61,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
||||
clearAnilistToken: () => {
|
||||
calls.push('clearAnilistToken');
|
||||
@@ -101,20 +97,15 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
getInvisibleWindow: () => null,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
setInvisibleIgnoreMouseEvents: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
@@ -138,7 +129,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
return { ok: true };
|
||||
},
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
@@ -176,25 +166,24 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
const modals: unknown[] = [];
|
||||
const closedModals: unknown[] = [];
|
||||
const openedModals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
getInvisibleWindow: () => null,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
setInvisibleIgnoreMouseEvents: () => {},
|
||||
onOverlayModalClosed: (modal) => {
|
||||
modals.push(modal);
|
||||
closedModals.push(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal) => {
|
||||
openedModals.push(modal);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: (position) => {
|
||||
@@ -214,7 +203,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
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: 42 });
|
||||
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)!({}, 'subsync');
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -19,20 +19,16 @@ import {
|
||||
} from '../../shared/ipc/validators';
|
||||
|
||||
export interface IpcServiceDeps {
|
||||
getInvisibleWindow: () => WindowLike | null;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
toggleVisibleOverlay: () => void;
|
||||
getInvisibleOverlayVisibility: () => boolean;
|
||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
@@ -54,7 +50,6 @@ export interface IpcServiceDeps {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -91,18 +86,16 @@ interface IpcMainRegistrar {
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getInvisibleWindow: () => WindowLike | null;
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
getInvisibleOverlayVisibility: () => boolean;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
@@ -119,7 +112,6 @@ export interface IpcDepsRuntimeOptions {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -130,14 +122,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
|
||||
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
|
||||
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,
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
quitApp: options.quitApp,
|
||||
toggleDevTools: () => {
|
||||
@@ -147,11 +133,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
},
|
||||
getVisibleOverlayVisibility: options.getVisibleOverlayVisibility,
|
||||
toggleVisibleOverlay: options.toggleVisibleOverlay,
|
||||
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
|
||||
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
||||
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
|
||||
getSubtitlePosition: options.getSubtitlePosition,
|
||||
getSubtitleStyle: options.getSubtitleStyle,
|
||||
saveSubtitlePosition: options.saveSubtitlePosition,
|
||||
@@ -182,7 +166,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
cycleRuntimeOption: options.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
||||
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
|
||||
getAnilistStatus: options.getAnilistStatus,
|
||||
clearAnilistToken: options.clearAnilistToken,
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
@@ -200,17 +183,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
const parsedOptions = parseOptionalForwardingOptions(options);
|
||||
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
if (
|
||||
senderWindow === invisibleWindow &&
|
||||
deps.isVisibleOverlayVisible() &&
|
||||
invisibleWindow &&
|
||||
!invisibleWindow.isDestroyed()
|
||||
) {
|
||||
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -220,6 +193,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
if (!parsedModal) return;
|
||||
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, () => {
|
||||
deps.openYomitanSettings();
|
||||
@@ -233,10 +212,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.toggleDevTools();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => {
|
||||
return deps.getVisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.toggleOverlay, () => {
|
||||
deps.toggleVisibleOverlay();
|
||||
});
|
||||
@@ -245,10 +220,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getVisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
|
||||
return deps.getInvisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
|
||||
return await deps.tokenizeCurrentSubtitle();
|
||||
});
|
||||
@@ -261,10 +232,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getCurrentSubtitleAss();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
|
||||
return deps.getMpvSubtitleRenderMetrics();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
|
||||
return deps.getSubtitlePosition();
|
||||
});
|
||||
@@ -358,17 +325,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
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, () => {
|
||||
return deps.getAnilistStatus();
|
||||
});
|
||||
|
||||
@@ -60,6 +60,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'sub-use-margins',
|
||||
'pause',
|
||||
'media-title',
|
||||
'secondary-sub-visibility',
|
||||
'sub-visibility',
|
||||
];
|
||||
|
||||
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
||||
|
||||
@@ -119,6 +119,36 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
|
||||
isVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-visibility', data: 'yes' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.commands.pop(), {
|
||||
command: ['set_property', 'sub-visibility', 'no'],
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage 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 () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
};
|
||||
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility?: () => boolean;
|
||||
emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void;
|
||||
emitSubtitleAssChange: (payload: { text: string }) => void;
|
||||
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
@@ -216,6 +217,14 @@ export async function dispatchMpvProtocolMessage(
|
||||
deps.emitSubtitleMetricsChange({
|
||||
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') {
|
||||
deps.emitSubtitleMetricsChange({
|
||||
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
|
||||
|
||||
@@ -306,6 +306,32 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
assert.equal(hasPathRequest, true);
|
||||
});
|
||||
|
||||
test('MpvIpcClient connect does not force primary subtitle visibility from binding path', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
|
||||
isVisibleOverlayVisible: () => true,
|
||||
}),
|
||||
);
|
||||
(client as any).send = (command: unknown) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
|
||||
const callbacks = (client as any).transport.callbacks;
|
||||
callbacks.onConnect();
|
||||
|
||||
const hasPrimaryVisibilityMutation = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'set_property' &&
|
||||
(command as { command: unknown[] }).command[1] === 'sub-visibility',
|
||||
);
|
||||
assert.equal(hasPrimaryVisibilityMutation, false);
|
||||
});
|
||||
|
||||
test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface MpvRuntimeClientLike {
|
||||
replayCurrentSubtitle?: () => void;
|
||||
playNextSubtitle?: () => void;
|
||||
setSubVisibility?: (visible: boolean) => void;
|
||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function showMpvOsdRuntime(
|
||||
@@ -84,6 +85,14 @@ export function setMpvSubVisibilityRuntime(
|
||||
mpvClient.setSubVisibility(visible);
|
||||
}
|
||||
|
||||
export function setMpvSecondarySubVisibilityRuntime(
|
||||
mpvClient: MpvRuntimeClientLike | null,
|
||||
visible: boolean,
|
||||
): void {
|
||||
if (!mpvClient?.setSecondarySubVisibility) return;
|
||||
mpvClient.setSecondarySubVisibility(visible);
|
||||
}
|
||||
|
||||
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
|
||||
|
||||
export interface MpvIpcClientProtocolDeps {
|
||||
@@ -181,8 +190,6 @@ export class MpvIpcClient implements MpvClient {
|
||||
setTimeout(() => {
|
||||
this.deps.setOverlayVisible(true);
|
||||
}, 100);
|
||||
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
|
||||
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
|
||||
}
|
||||
|
||||
this.firstConnection = false;
|
||||
@@ -290,6 +297,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
getResolvedConfig: () => this.deps.getResolvedConfig(),
|
||||
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
|
||||
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () =>
|
||||
this.deps.shouldBindVisibleOverlayToMpvSubVisibility(),
|
||||
emitSubtitleChange: (payload) => {
|
||||
this.emit('subtitle-change', payload);
|
||||
},
|
||||
@@ -464,8 +473,13 @@ export class MpvIpcClient implements MpvClient {
|
||||
}
|
||||
|
||||
setSubVisibility(visible: boolean): void {
|
||||
const value = visible ? 'yes' : 'no';
|
||||
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;
|
||||
}
|
||||
|
||||
private setSecondarySubVisibility(visible: boolean): void {
|
||||
setSecondarySubVisibility(visible: boolean): void {
|
||||
this.send({
|
||||
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
|
||||
});
|
||||
|
||||
@@ -33,13 +33,51 @@ test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden o
|
||||
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 () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next) => {
|
||||
resolver = next;
|
||||
|
||||
@@ -13,6 +13,11 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
|
||||
}): boolean {
|
||||
if (!options.mainWindow || options.mainWindow.isDestroyed()) return false;
|
||||
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) {
|
||||
options.setVisibleOverlayVisible(true);
|
||||
}
|
||||
@@ -21,32 +26,40 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
|
||||
}
|
||||
const sendNow = (): void => {
|
||||
if (options.payload === undefined) {
|
||||
options.mainWindow!.webContents.send(options.channel);
|
||||
webContents.send(options.channel);
|
||||
} 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', () => {
|
||||
if (
|
||||
options.mainWindow &&
|
||||
!options.mainWindow.isDestroyed() &&
|
||||
!options.mainWindow.webContents.isLoading()
|
||||
) {
|
||||
|
||||
const isLoading = typeof webContents.isLoading === 'function' ? webContents.isLoading() : false;
|
||||
const currentURL = typeof webContents.getURL === 'function' ? webContents.getURL() : '';
|
||||
const isReady =
|
||||
!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();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
sendNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendToVisibleOverlay: (
|
||||
@@ -57,9 +70,7 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendRequestToVisibleOverlay: (data) =>
|
||||
|
||||
@@ -28,7 +28,7 @@ test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', (
|
||||
test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
|
||||
const measurement = sanitizeOverlayContentMeasurement(
|
||||
{
|
||||
layer: 'invisible',
|
||||
layer: 'visible',
|
||||
measuredAtMs: 100,
|
||||
viewport: { width: 0, height: 1080 },
|
||||
contentRect: { x: 0, y: 0, width: 100, height: 20 },
|
||||
@@ -39,7 +39,7 @@ test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
|
||||
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({
|
||||
now: () => 1000,
|
||||
warn: () => {
|
||||
@@ -53,17 +53,9 @@ test('overlay measurement store keeps latest payload per layer', () => {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
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(invisible?.layer, 'invisible');
|
||||
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', () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function sanitizeOverlayContentMeasurement(
|
||||
} | null;
|
||||
};
|
||||
|
||||
if (candidate.layer !== 'visible' && candidate.layer !== 'invisible') {
|
||||
if (candidate.layer !== 'visible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ export function createOverlayContentMeasurementStore(options?: {
|
||||
const warn = options?.warn ?? ((message: string) => logger.warn(message));
|
||||
const latestByLayer: OverlayMeasurementStore = {
|
||||
visible: null,
|
||||
invisible: null,
|
||||
};
|
||||
|
||||
let droppedInvalid = 0;
|
||||
|
||||
@@ -9,11 +9,8 @@ import {
|
||||
test('overlay manager initializes with empty windows and hidden overlays', () => {
|
||||
const manager = createOverlayManager();
|
||||
assert.equal(manager.getMainWindow(), null);
|
||||
assert.equal(manager.getInvisibleWindow(), null);
|
||||
assert.equal(manager.getSecondaryWindow(), null);
|
||||
assert.equal(manager.getModalWindow(), null);
|
||||
assert.equal(manager.getVisibleOverlayVisible(), false);
|
||||
assert.equal(manager.getInvisibleOverlayVisible(), false);
|
||||
assert.deepEqual(manager.getOverlayWindows(), []);
|
||||
});
|
||||
|
||||
@@ -22,28 +19,17 @@ test('overlay manager stores window references and returns stable window order',
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const invisibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const secondaryWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const modalWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
|
||||
manager.setMainWindow(visibleWindow);
|
||||
manager.setInvisibleWindow(invisibleWindow);
|
||||
manager.setSecondaryWindow(secondaryWindow);
|
||||
manager.setModalWindow(modalWindow);
|
||||
|
||||
assert.equal(manager.getMainWindow(), visibleWindow);
|
||||
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
|
||||
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
|
||||
assert.equal(manager.getModalWindow(), modalWindow);
|
||||
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
|
||||
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
|
||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
|
||||
assert.equal(manager.getOverlayWindow(), visibleWindow);
|
||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]);
|
||||
});
|
||||
|
||||
test('overlay manager excludes destroyed windows', () => {
|
||||
@@ -51,26 +37,18 @@ test('overlay manager excludes destroyed windows', () => {
|
||||
manager.setMainWindow({
|
||||
isDestroyed: () => true,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.setInvisibleWindow({
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.setSecondaryWindow({
|
||||
isDestroyed: () => true,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.setModalWindow({
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
|
||||
assert.equal(manager.getOverlayWindows().length, 1);
|
||||
assert.equal(manager.getOverlayWindows().length, 0);
|
||||
});
|
||||
|
||||
test('overlay manager stores visibility state', () => {
|
||||
const manager = createOverlayManager();
|
||||
|
||||
manager.setVisibleOverlayVisible(true);
|
||||
manager.setInvisibleOverlayVisible(true);
|
||||
assert.equal(manager.getVisibleOverlayVisible(), true);
|
||||
assert.equal(manager.getInvisibleOverlayVisible(), true);
|
||||
});
|
||||
|
||||
test('overlay manager broadcasts to non-destroyed windows', () => {
|
||||
@@ -84,58 +62,25 @@ test('overlay manager broadcasts to non-destroyed windows', () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const deadWindow = {
|
||||
isDestroyed: () => true,
|
||||
webContents: {
|
||||
send: (..._args: unknown[]) => {},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const secondaryWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
|
||||
manager.setMainWindow(aliveWindow);
|
||||
manager.setInvisibleWindow(deadWindow);
|
||||
manager.setSecondaryWindow(secondaryWindow);
|
||||
manager.setModalWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: () => {} },
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.broadcastToOverlayWindows('x', 1, 'a');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['x', 1, 'a'],
|
||||
['x', 1, 'a'],
|
||||
]);
|
||||
assert.deepEqual(calls, [['x', 1, 'a']]);
|
||||
});
|
||||
|
||||
test('overlay manager applies bounds by layer', () => {
|
||||
test('overlay manager applies bounds for main and modal windows', () => {
|
||||
const manager = createOverlayManager();
|
||||
const visibleCalls: Electron.Rectangle[] = [];
|
||||
const invisibleCalls: Electron.Rectangle[] = [];
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
visibleCalls.push(bounds);
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const invisibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
invisibleCalls.push(bounds);
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const secondaryWindow = {
|
||||
isDestroyed: () => false,
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
invisibleCalls.push(bounds);
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const modalCalls: Electron.Rectangle[] = [];
|
||||
const modalWindow = {
|
||||
isDestroyed: () => false,
|
||||
@@ -144,28 +89,14 @@ test('overlay manager applies bounds by layer', () => {
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
manager.setMainWindow(visibleWindow);
|
||||
manager.setInvisibleWindow(invisibleWindow);
|
||||
manager.setSecondaryWindow(secondaryWindow);
|
||||
manager.setModalWindow(modalWindow);
|
||||
|
||||
manager.setOverlayWindowBounds('visible', {
|
||||
manager.setOverlayWindowBounds({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
});
|
||||
manager.setOverlayWindowBounds('invisible', {
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
});
|
||||
manager.setSecondaryWindowBounds({
|
||||
x: 8,
|
||||
y: 9,
|
||||
width: 10,
|
||||
height: 11,
|
||||
});
|
||||
manager.setModalWindowBounds({
|
||||
x: 80,
|
||||
y: 90,
|
||||
@@ -174,14 +105,10 @@ test('overlay manager applies bounds by layer', () => {
|
||||
});
|
||||
|
||||
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
|
||||
assert.deepEqual(invisibleCalls, [
|
||||
{ x: 1, y: 2, width: 3, height: 4 },
|
||||
{ x: 8, y: 9, width: 10, height: 11 },
|
||||
]);
|
||||
assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]);
|
||||
});
|
||||
|
||||
test('runtime-option and debug broadcasts use expected channels', () => {
|
||||
test('runtime-option broadcast still uses expected channel', () => {
|
||||
const broadcasts: unknown[][] = [];
|
||||
broadcastRuntimeOptionsChangedRuntime(
|
||||
() => [],
|
||||
@@ -196,14 +123,8 @@ test('runtime-option and debug broadcasts use expected channels', () => {
|
||||
(enabled) => {
|
||||
state = enabled;
|
||||
},
|
||||
(channel, ...args) => {
|
||||
broadcasts.push([channel, ...args]);
|
||||
},
|
||||
);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(state, true);
|
||||
assert.deepEqual(broadcasts, [
|
||||
['runtime-options:changed', []],
|
||||
['overlay-debug-visualization:set', true],
|
||||
]);
|
||||
assert.deepEqual(broadcasts, [['runtime-options:changed', []]]);
|
||||
});
|
||||
|
||||
@@ -2,60 +2,37 @@ import type { BrowserWindow } from 'electron';
|
||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||
import { updateOverlayWindowBounds } from './overlay-window';
|
||||
|
||||
type OverlayLayer = 'visible' | 'invisible';
|
||||
|
||||
export interface OverlayManager {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
setMainWindow: (window: BrowserWindow | null) => void;
|
||||
getInvisibleWindow: () => BrowserWindow | null;
|
||||
setInvisibleWindow: (window: BrowserWindow | null) => void;
|
||||
getSecondaryWindow: () => BrowserWindow | null;
|
||||
setSecondaryWindow: (window: BrowserWindow | null) => void;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
setModalWindow: (window: BrowserWindow | null) => void;
|
||||
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
|
||||
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
|
||||
getOverlayWindow: () => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createOverlayManager(): OverlayManager {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let invisibleWindow: BrowserWindow | null = null;
|
||||
let secondaryWindow: BrowserWindow | null = null;
|
||||
let modalWindow: BrowserWindow | null = null;
|
||||
let visibleOverlayVisible = false;
|
||||
let invisibleOverlayVisible = false;
|
||||
|
||||
return {
|
||||
getMainWindow: () => mainWindow,
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
},
|
||||
getInvisibleWindow: () => invisibleWindow,
|
||||
setInvisibleWindow: (window) => {
|
||||
invisibleWindow = window;
|
||||
},
|
||||
getSecondaryWindow: () => secondaryWindow,
|
||||
setSecondaryWindow: (window) => {
|
||||
secondaryWindow = window;
|
||||
},
|
||||
getModalWindow: () => modalWindow,
|
||||
setModalWindow: (window) => {
|
||||
modalWindow = window;
|
||||
},
|
||||
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
|
||||
setOverlayWindowBounds: (layer, geometry) => {
|
||||
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
|
||||
},
|
||||
setSecondaryWindowBounds: (geometry) => {
|
||||
updateOverlayWindowBounds(geometry, secondaryWindow);
|
||||
getOverlayWindow: () => mainWindow,
|
||||
setOverlayWindowBounds: (geometry) => {
|
||||
updateOverlayWindowBounds(geometry, mainWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => {
|
||||
updateOverlayWindowBounds(geometry, modalWindow);
|
||||
@@ -64,36 +41,12 @@ export function createOverlayManager(): OverlayManager {
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
},
|
||||
getInvisibleOverlayVisible: () => invisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
invisibleOverlayVisible = visible;
|
||||
},
|
||||
getOverlayWindows: () => {
|
||||
const windows: BrowserWindow[] = [];
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
windows.push(mainWindow);
|
||||
}
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
windows.push(invisibleWindow);
|
||||
}
|
||||
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
|
||||
windows.push(secondaryWindow);
|
||||
}
|
||||
return windows;
|
||||
return mainWindow && !mainWindow.isDestroyed() ? [mainWindow] : [];
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, ...args) => {
|
||||
const windows: BrowserWindow[] = [];
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
windows.push(mainWindow);
|
||||
}
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
windows.push(invisibleWindow);
|
||||
}
|
||||
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
|
||||
windows.push(secondaryWindow);
|
||||
}
|
||||
for (const window of windows) {
|
||||
window.webContents.send(channel, ...args);
|
||||
mainWindow.webContents.send(channel, ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -110,10 +63,8 @@ export function setOverlayDebugVisualizationEnabledRuntime(
|
||||
currentEnabled: boolean,
|
||||
nextEnabled: boolean,
|
||||
setState: (enabled: boolean) => void,
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
||||
): boolean {
|
||||
if (currentEnabled === nextEnabled) return false;
|
||||
setState(nextEnabled);
|
||||
broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -10,16 +10,11 @@ import {
|
||||
|
||||
export function initializeOverlayRuntime(options: {
|
||||
backendOverride: string | null;
|
||||
getInitialInvisibleOverlayVisibility: () => boolean;
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
isInvisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
@@ -38,12 +33,8 @@ export function initializeOverlayRuntime(options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
}): {
|
||||
invisibleOverlayVisible: boolean;
|
||||
} {
|
||||
}): void {
|
||||
options.createMainWindow();
|
||||
options.createInvisibleWindow();
|
||||
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
|
||||
options.registerGlobalShortcuts();
|
||||
|
||||
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
|
||||
@@ -51,17 +42,12 @@ export function initializeOverlayRuntime(options: {
|
||||
if (windowTracker) {
|
||||
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.updateInvisibleOverlayBounds(geometry);
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.updateInvisibleOverlayBounds(geometry);
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
}
|
||||
if (options.isInvisibleOverlayVisible()) {
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
}
|
||||
};
|
||||
windowTracker.onWindowLost = () => {
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
@@ -101,7 +87,4 @@ export function initializeOverlayRuntime(options: {
|
||||
}
|
||||
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
|
||||
return { invisibleOverlayVisible };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: null,
|
||||
toggleInvisibleOverlayGlobal: null,
|
||||
copySubtitle: null,
|
||||
copySubtitleMultiple: null,
|
||||
updateLastCardFromClipboard: null,
|
||||
|
||||
265
src/core/services/overlay-visibility.test.ts
Normal file
265
src/core/services/overlay-visibility.test.ts
Normal 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...']);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
|
||||
@@ -10,14 +10,19 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
}): void {
|
||||
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
@@ -29,6 +34,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
@@ -38,7 +45,18 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
|
||||
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.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
@@ -49,16 +67,23 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
}
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const fallbackBounds = display.workArea;
|
||||
args.updateVisibleOverlayBounds({
|
||||
x: fallbackBounds.x,
|
||||
y: fallbackBounds.y,
|
||||
width: fallbackBounds.width,
|
||||
height: fallbackBounds.height,
|
||||
});
|
||||
|
||||
if (args.isMacOSPlatform) {
|
||||
args.mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackBounds = args.resolveFallbackBounds?.();
|
||||
if (!fallbackBounds) return;
|
||||
|
||||
args.updateVisibleOverlayBounds(fallbackBounds);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
@@ -66,111 +91,11 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
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: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isMpvConnected: () => boolean;
|
||||
setMpvSubVisibility: (visible: boolean) => void;
|
||||
}): void {
|
||||
options.setVisibleOverlayVisibleState(options.visible);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2;
|
||||
|
||||
function toInteger(value: number): number {
|
||||
return Number.isFinite(value) ? Math.round(value) : 0;
|
||||
}
|
||||
|
||||
function clampPositive(value: number): number {
|
||||
return Math.max(1, toInteger(value));
|
||||
}
|
||||
|
||||
export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): {
|
||||
secondary: WindowGeometry;
|
||||
primary: WindowGeometry;
|
||||
} {
|
||||
const x = toInteger(geometry.x);
|
||||
const y = toInteger(geometry.y);
|
||||
const width = clampPositive(geometry.width);
|
||||
const totalHeight = clampPositive(geometry.height);
|
||||
|
||||
const secondaryHeight = clampPositive(
|
||||
Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)),
|
||||
);
|
||||
const primaryHeight = clampPositive(totalHeight - secondaryHeight);
|
||||
|
||||
return {
|
||||
secondary: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: secondaryHeight,
|
||||
},
|
||||
primary: {
|
||||
x,
|
||||
y: y + secondaryHeight,
|
||||
width,
|
||||
height: primaryHeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry';
|
||||
|
||||
test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => {
|
||||
const regions = splitOverlayGeometryForSecondaryBar({
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
});
|
||||
|
||||
assert.deepEqual(regions.secondary, {
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 1200,
|
||||
height: 180,
|
||||
});
|
||||
assert.deepEqual(regions.primary, {
|
||||
x: 100,
|
||||
y: 230,
|
||||
width: 1200,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
|
||||
test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => {
|
||||
const regions = splitOverlayGeometryForSecondaryBar({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
assert.ok(regions.secondary.height >= 1);
|
||||
assert.ok(regions.primary.height >= 1);
|
||||
});
|
||||
@@ -4,8 +4,25 @@ import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
|
||||
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||
function getOverlayWindowHtmlPath(): string {
|
||||
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||
}
|
||||
|
||||
function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind): void {
|
||||
overlayWindowLayerByInstance.set(window, layer);
|
||||
const htmlPath = getOverlayWindowHtmlPath();
|
||||
window
|
||||
.loadFile(htmlPath, {
|
||||
query: { layer },
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load HTML file:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
export function updateOverlayWindowBounds(
|
||||
geometry: WindowGeometry,
|
||||
@@ -32,14 +49,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
|
||||
export function enforceOverlayLayerOrder(options: {
|
||||
visibleOverlayVisible: boolean;
|
||||
invisibleOverlayVisible: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
invisibleWindow: BrowserWindow | null;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
}): void {
|
||||
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return;
|
||||
if (!options.visibleOverlayVisible) return;
|
||||
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
|
||||
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
|
||||
|
||||
options.ensureOverlayWindowLevel(options.mainWindow);
|
||||
options.mainWindow.moveTop();
|
||||
@@ -49,7 +63,6 @@ export function createOverlayWindow(
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
isDev: boolean;
|
||||
overlayDebugVisualizationEnabled: boolean;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
@@ -83,16 +96,7 @@ export function createOverlayWindow(
|
||||
});
|
||||
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||
|
||||
window
|
||||
.loadFile(htmlPath, {
|
||||
query: { layer: kind },
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load HTML file:', err);
|
||||
});
|
||||
loadOverlayWindowLayer(window, kind);
|
||||
|
||||
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
|
||||
@@ -100,10 +104,6 @@ export function createOverlayWindow(
|
||||
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
options.onRuntimeOptionsChanged();
|
||||
window.webContents.send(
|
||||
'overlay-debug-visualization:set',
|
||||
options.overlayDebugVisualizationEnabled,
|
||||
);
|
||||
});
|
||||
|
||||
if (kind === 'visible') {
|
||||
@@ -117,7 +117,7 @@ export function createOverlayWindow(
|
||||
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (kind === 'modal') return;
|
||||
if (!options.isOverlayVisible(kind)) return;
|
||||
if (!window.isVisible()) return;
|
||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
@@ -140,3 +140,9 @@ export function createOverlayWindow(
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): void {
|
||||
if (window.isDestroyed()) return;
|
||||
if (overlayWindowLayerByInstance.get(window) === layer) return;
|
||||
loadOverlayWindowLayer(window, layer);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getInitialInvisibleOverlayVisibility,
|
||||
isAutoUpdateEnabledRuntime,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
shouldBindVisibleOverlayToMpvSubVisibility,
|
||||
@@ -10,9 +9,6 @@ import {
|
||||
const BASE_CONFIG = {
|
||||
auto_start_overlay: false,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: true,
|
||||
invisibleOverlay: {
|
||||
startupVisibility: 'platform-default' as const,
|
||||
},
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
autoUpdateNewCards: true,
|
||||
@@ -20,26 +16,7 @@ const BASE_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => {
|
||||
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', () => {
|
||||
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start', () => {
|
||||
assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false);
|
||||
assert.equal(
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig({
|
||||
@@ -48,13 +25,6 @@ test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visib
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig({
|
||||
...BASE_CONFIG,
|
||||
invisibleOverlay: { startupVisibility: 'visible' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => {
|
||||
|
||||
@@ -5,14 +5,12 @@ const logger = createLogger('main:shortcut');
|
||||
|
||||
export interface GlobalShortcutConfig {
|
||||
toggleVisibleOverlayGlobal: string | null | undefined;
|
||||
toggleInvisibleOverlayGlobal: string | null | undefined;
|
||||
openJimaku?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface RegisterGlobalShortcutsServiceOptions {
|
||||
shortcuts: GlobalShortcutConfig;
|
||||
onToggleVisibleOverlay: () => void;
|
||||
onToggleInvisibleOverlay: () => void;
|
||||
onOpenYomitanSettings: () => void;
|
||||
onOpenJimaku?: () => void;
|
||||
isDev: boolean;
|
||||
@@ -21,9 +19,7 @@ export interface RegisterGlobalShortcutsServiceOptions {
|
||||
|
||||
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
|
||||
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
|
||||
const invisibleShortcut = options.shortcuts.toggleInvisibleOverlayGlobal;
|
||||
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
|
||||
const normalizedInvisible = invisibleShortcut?.replace(/\s+/g, '').toLowerCase();
|
||||
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
|
||||
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 (
|
||||
normalizedJimaku &&
|
||||
(normalizedJimaku === normalizedVisible ||
|
||||
normalizedJimaku === normalizedInvisible ||
|
||||
normalizedJimaku === normalizedSettings)
|
||||
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
|
||||
) {
|
||||
logger.warn(
|
||||
'Skipped registering openJimaku because it collides with another global shortcut',
|
||||
|
||||
@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
|
||||
@@ -19,9 +19,6 @@ interface RuntimeAutoUpdateOptionManagerLike {
|
||||
export interface RuntimeConfigLike {
|
||||
auto_start_overlay?: boolean;
|
||||
bind_visible_overlay_to_mpv_sub_visibility: boolean;
|
||||
invisibleOverlay: {
|
||||
startupVisibility: 'visible' | 'hidden' | 'platform-default';
|
||||
};
|
||||
ankiConnect?: {
|
||||
behavior?: {
|
||||
autoUpdateNewCards?: boolean;
|
||||
@@ -155,21 +152,8 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
|
||||
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 {
|
||||
if (config.auto_start_overlay === true) return true;
|
||||
if (config.invisibleOverlay.startupVisibility === 'visible') return true;
|
||||
return false;
|
||||
return config.auto_start_overlay === true;
|
||||
}
|
||||
|
||||
export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean {
|
||||
|
||||
@@ -101,20 +101,7 @@ export function loadSubtitlePosition(
|
||||
const data = fs.readFileSync(positionPath, 'utf-8');
|
||||
const parsed = JSON.parse(data) as Partial<SubtitlePosition>;
|
||||
if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) {
|
||||
const position: SubtitlePosition = { 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 { yPercent: parsed.yPercent };
|
||||
}
|
||||
return options.fallbackPosition;
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Config } from '../../types';
|
||||
|
||||
export interface ConfiguredShortcuts {
|
||||
toggleVisibleOverlayGlobal: string | null | undefined;
|
||||
toggleInvisibleOverlayGlobal: string | null | undefined;
|
||||
copySubtitle: string | null | undefined;
|
||||
copySubtitleMultiple: string | null | undefined;
|
||||
updateLastCardFromClipboard: string | null | undefined;
|
||||
@@ -33,10 +32,6 @@ export function resolveConfiguredShortcuts(
|
||||
config.shortcuts?.toggleVisibleOverlayGlobal ??
|
||||
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
|
||||
),
|
||||
toggleInvisibleOverlayGlobal: normalizeShortcut(
|
||||
config.shortcuts?.toggleInvisibleOverlayGlobal ??
|
||||
defaultConfig.shortcuts?.toggleInvisibleOverlayGlobal,
|
||||
),
|
||||
copySubtitle: normalizeShortcut(
|
||||
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
|
||||
),
|
||||
|
||||
342
src/main.ts
342
src/main.ts
@@ -30,6 +30,41 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith('--password-store')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--password-store') {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === '--password-store' && value && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePasswordStoreArg(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.toLowerCase() === 'gnome') {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getDefaultPasswordStore(): string {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
@@ -183,7 +218,6 @@ import {
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler,
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||
createOverlayWindowRuntimeHandlers,
|
||||
createOverlayRuntimeBootstrapHandlers,
|
||||
@@ -199,7 +233,6 @@ import {
|
||||
createSetOverlayDebugVisualizationEnabledHandler,
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateInvisibleOverlayBoundsHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
createLoadSubtitlePositionHandler,
|
||||
createSaveSubtitlePositionHandler,
|
||||
@@ -321,16 +354,15 @@ import {
|
||||
runStartupBootstrapRuntime,
|
||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||
sendMpvCommandRuntime,
|
||||
setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore,
|
||||
setMpvSubVisibilityRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
syncOverlayWindowLayer,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
showMpvOsdRuntime,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
} from './core/services';
|
||||
import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry';
|
||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
@@ -341,7 +373,10 @@ import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options
|
||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
|
||||
import { createMainRuntimeRegistry } from './main/runtime/registry';
|
||||
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight';
|
||||
import {
|
||||
createEnsureOverlayMpvSubtitlesHiddenHandler,
|
||||
createRestoreOverlayMpvSubtitlesHandler,
|
||||
} from './main/runtime/overlay-mpv-sub-visibility';
|
||||
import {
|
||||
composeAnilistSetupHandlers,
|
||||
composeAnilistTrackingHandlers,
|
||||
@@ -400,6 +435,9 @@ import { resolveConfigDir } from './config/path-resolution';
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||
const passwordStore = normalizePasswordStoreArg(getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore());
|
||||
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||
console.debug(`[main] Applied --password-store ${passwordStore}`);
|
||||
}
|
||||
|
||||
app.setName('SubMiner');
|
||||
@@ -447,6 +485,7 @@ let jellyfinRemoteLastProgressAtMs = 0;
|
||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let backgroundWarmupsStarted = false;
|
||||
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
||||
let notifyAnilistTokenStoreWarning: (message: string) => void = () => {};
|
||||
|
||||
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
|
||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
||||
@@ -496,6 +535,7 @@ const anilistTokenStore = createAnilistTokenStore(
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
warnUser: (message: string) => notifyAnilistTokenStoreWarning(message),
|
||||
},
|
||||
);
|
||||
const jellyfinTokenStore = createJellyfinTokenStore(
|
||||
@@ -518,6 +558,16 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
|
||||
const texthookerService = new Texthooker();
|
||||
const subtitleWsService = new SubtitleWebSocket();
|
||||
const logger = createLogger('main');
|
||||
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||
logger.warn(`[AniList] ${message}`);
|
||||
try {
|
||||
showDesktopNotification('SubMiner AniList', {
|
||||
body: message,
|
||||
});
|
||||
} catch {
|
||||
// Notification may fail if desktop notifications are unavailable early in startup.
|
||||
}
|
||||
};
|
||||
const appLogger = {
|
||||
logInfo: (message: string) => {
|
||||
logger.info(message);
|
||||
@@ -594,7 +644,6 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
|
||||
});
|
||||
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
createModalWindow: () => createModalWindow(),
|
||||
getModalGeometry: () => getCurrentOverlayGeometry(),
|
||||
@@ -675,13 +724,55 @@ async function initializeDiscordPresenceService(): Promise<void> {
|
||||
await appState.discordPresenceService.start();
|
||||
publishDiscordPresence();
|
||||
}
|
||||
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({
|
||||
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentSubtitleData: () => appState.currentSubtitleData,
|
||||
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex,
|
||||
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision,
|
||||
getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null,
|
||||
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
|
||||
setSavedSubVisibility: (visible) => {
|
||||
appState.overlaySavedMpvSubVisibility = visible;
|
||||
},
|
||||
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({
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
|
||||
@@ -716,7 +807,6 @@ const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMa
|
||||
const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
platform: process.platform,
|
||||
defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference,
|
||||
defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults,
|
||||
defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl,
|
||||
@@ -751,15 +841,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
return await tokenizeSubtitle(text);
|
||||
},
|
||||
emitSubtitle: (payload) => {
|
||||
const previousSubtitleText = appState.currentSubtitleData?.text ?? null;
|
||||
const nextSubtitleText = payload?.text ?? null;
|
||||
const subtitleChanged = previousSubtitleText !== nextSubtitleText;
|
||||
appState.currentSubtitleData = payload;
|
||||
if (subtitleChanged) {
|
||||
appState.hoveredSubtitleTokenIndex = null;
|
||||
appState.hoveredSubtitleRevision += 1;
|
||||
applyHoveredTokenOverlay();
|
||||
}
|
||||
broadcastToOverlayWindows('subtitle:set', payload);
|
||||
subtitleWsService.broadcast(payload, {
|
||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
@@ -800,7 +882,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
copySubtitle: () => {
|
||||
copyCurrentSubtitle();
|
||||
},
|
||||
toggleSecondarySubMode: () => cycleSecondarySubMode(),
|
||||
toggleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
||||
triggerFieldGrouping: () => triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
@@ -849,8 +931,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
refreshGlobalAndOverlayShortcuts();
|
||||
},
|
||||
setSecondarySubMode: (mode) => {
|
||||
appState.secondarySubMode = mode;
|
||||
syncSecondaryOverlayWindowVisibility();
|
||||
setSecondarySubMode(mode);
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
broadcastToOverlayWindows(channel, payload);
|
||||
@@ -973,9 +1054,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
||||
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
|
||||
setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
|
||||
getResolver: () => getFieldGroupingResolver(),
|
||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
@@ -1017,26 +1096,40 @@ const mediaRuntime = createMediaRuntimeService(
|
||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
appState.trackerNotReadyWarningShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
updateInvisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window) => {
|
||||
ensureOverlayWindowLevel(window);
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||
syncPrimaryOverlayWindowLayer(layer);
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
enforceOverlayLayerOrder();
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||
},
|
||||
isMacOSPlatform: () => process.platform === 'darwin',
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
resolveFallbackBounds: () => {
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const fallbackBounds = display.workArea;
|
||||
return {
|
||||
x: fallbackBounds.x,
|
||||
y: fallbackBounds.y,
|
||||
width: fallbackBounds.width,
|
||||
height: fallbackBounds.height,
|
||||
};
|
||||
},
|
||||
})(),
|
||||
);
|
||||
|
||||
@@ -1115,7 +1208,6 @@ const buildSetOverlayDebugVisualizationEnabledMainDepsHandler =
|
||||
setCurrentEnabled: (next) => {
|
||||
appState.overlayDebugVisualizationEnabled = next;
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
|
||||
});
|
||||
const setOverlayDebugVisualizationEnabledMainDeps =
|
||||
buildSetOverlayDebugVisualizationEnabledMainDepsHandler();
|
||||
@@ -1776,6 +1868,9 @@ const {
|
||||
destroyTray: () => destroyTray(),
|
||||
stopConfigHotReload: () => configHotReloadRuntime.stop(),
|
||||
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
|
||||
restoreMpvSubVisibility: () => {
|
||||
restoreOverlayMpvSubtitles();
|
||||
},
|
||||
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
|
||||
stopSubtitleWebsocket: () => subtitleWsService.stop(),
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
@@ -1820,14 +1915,11 @@ const {
|
||||
createMainWindow: () => {
|
||||
createMainWindow();
|
||||
},
|
||||
createInvisibleWindow: () => {
|
||||
createInvisibleWindow();
|
||||
},
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
updateInvisibleOverlayVisibility: () => {
|
||||
overlayVisibilityRuntime.updateInvisibleOverlayVisibility();
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1884,8 +1976,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
|
||||
);
|
||||
},
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
appState.secondarySubMode = mode;
|
||||
syncSecondaryOverlayWindowVisibility();
|
||||
setSecondarySubMode(mode);
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
|
||||
@@ -2074,6 +2165,9 @@ const {
|
||||
updateCurrentMediaPath: (path) => {
|
||||
mediaRuntime.updateCurrentMediaPath(path);
|
||||
},
|
||||
restoreMpvSubVisibility: () => {
|
||||
restoreOverlayMpvSubtitles();
|
||||
},
|
||||
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => {
|
||||
resetAnilistMediaTracking(mediaKey);
|
||||
@@ -2099,6 +2193,9 @@ const {
|
||||
updateSubtitleRenderMetrics: (patch) => {
|
||||
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
},
|
||||
mpvClientRuntimeServiceFactoryMainDeps: {
|
||||
createClient: MpvIpcClient,
|
||||
@@ -2120,8 +2217,8 @@ const {
|
||||
appState.mpvSubtitleRenderMetrics = metrics;
|
||||
},
|
||||
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
|
||||
broadcastMetrics: (metrics) => {
|
||||
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics);
|
||||
broadcastMetrics: () => {
|
||||
// no renderer consumer for subtitle render metrics updates at present
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
@@ -2226,52 +2323,21 @@ function getCurrentOverlayGeometry(): WindowGeometry {
|
||||
return getOverlayGeometryFallback();
|
||||
}
|
||||
|
||||
function syncSecondaryOverlayWindowVisibility(): void {
|
||||
const secondaryWindow = overlayManager.getSecondaryWindow();
|
||||
if (!secondaryWindow || secondaryWindow.isDestroyed()) return;
|
||||
|
||||
if (appState.secondarySubMode === 'hidden') {
|
||||
secondaryWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
secondaryWindow.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
secondaryWindow.setIgnoreMouseEvents(false);
|
||||
ensureOverlayWindowLevel(secondaryWindow);
|
||||
if (typeof secondaryWindow.showInactive === 'function') {
|
||||
secondaryWindow.showInactive();
|
||||
} else {
|
||||
secondaryWindow.show();
|
||||
}
|
||||
}
|
||||
|
||||
function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeometry): void {
|
||||
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||
lastOverlayWindowGeometry = geometry;
|
||||
const regions = splitOverlayGeometryForSecondaryBar(geometry);
|
||||
overlayManager.setOverlayWindowBounds(layer, regions.primary);
|
||||
overlayManager.setSecondaryWindowBounds(regions.secondary);
|
||||
overlayManager.setOverlayWindowBounds(geometry);
|
||||
overlayManager.setModalWindowBounds(geometry);
|
||||
syncSecondaryOverlayWindowVisibility();
|
||||
}
|
||||
|
||||
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
});
|
||||
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||
updateVisibleOverlayBoundsMainDeps,
|
||||
);
|
||||
|
||||
const buildUpdateInvisibleOverlayBoundsMainDepsHandler =
|
||||
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({
|
||||
setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry),
|
||||
});
|
||||
const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler();
|
||||
const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler(
|
||||
updateInvisibleOverlayBoundsMainDeps,
|
||||
);
|
||||
|
||||
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||
@@ -2281,21 +2347,23 @@ const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||
ensureOverlayWindowLevelMainDeps,
|
||||
);
|
||||
|
||||
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
syncOverlayWindowLayer(mainWindow, layer);
|
||||
}
|
||||
|
||||
const buildEnforceOverlayLayerOrderMainDepsHandler =
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||
enforceOverlayLayerOrderCore: (params) =>
|
||||
enforceOverlayLayerOrderCore({
|
||||
visibleOverlayVisible: params.visibleOverlayVisible,
|
||||
invisibleOverlayVisible: params.invisibleOverlayVisible,
|
||||
mainWindow: params.mainWindow as BrowserWindow | null,
|
||||
invisibleWindow: params.invisibleWindow as BrowserWindow | null,
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
params.ensureOverlayWindowLevel(window as BrowserWindow),
|
||||
}),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
|
||||
});
|
||||
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
|
||||
@@ -2311,7 +2379,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
||||
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||
}
|
||||
|
||||
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow {
|
||||
function createOverlayWindow(kind: 'visible' | 'modal'): BrowserWindow {
|
||||
return createOverlayWindowHandler(kind);
|
||||
}
|
||||
|
||||
@@ -2325,25 +2393,9 @@ function createModalWindow(): BrowserWindow {
|
||||
return window;
|
||||
}
|
||||
|
||||
function createSecondaryWindow(): BrowserWindow {
|
||||
const existingWindow = overlayManager.getSecondaryWindow();
|
||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||
return existingWindow;
|
||||
}
|
||||
const window = createSecondaryWindowHandler();
|
||||
applyOverlayRegions('visible', getCurrentOverlayGeometry());
|
||||
return window;
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const window = createMainWindowHandler();
|
||||
createSecondaryWindow();
|
||||
return window;
|
||||
return createMainWindowHandler();
|
||||
}
|
||||
function createInvisibleWindow(): BrowserWindow {
|
||||
return createInvisibleWindowHandler();
|
||||
}
|
||||
|
||||
function resolveTrayIconPath(): string | null {
|
||||
return resolveTrayIconPathHandler();
|
||||
}
|
||||
@@ -2362,6 +2414,7 @@ function destroyTray(): void {
|
||||
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function openYomitanSettings(): void {
|
||||
@@ -2391,7 +2444,6 @@ const {
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore,
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
isDev,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -2445,8 +2497,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
cycleSecondarySubModeMainDeps: {
|
||||
getSecondarySubMode: () => appState.secondarySubMode,
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
appState.secondarySubMode = mode;
|
||||
syncSecondaryOverlayWindowVisibility();
|
||||
setSecondarySubMode(mode);
|
||||
},
|
||||
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
|
||||
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
|
||||
@@ -2460,6 +2511,14 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
||||
});
|
||||
|
||||
function setSecondarySubMode(mode: SecondarySubMode): void {
|
||||
appState.secondarySubMode = mode;
|
||||
}
|
||||
|
||||
function handleCycleSecondarySubMode(): void {
|
||||
cycleSecondarySubMode();
|
||||
}
|
||||
|
||||
async function triggerSubsyncFromConfig(): Promise<void> {
|
||||
await subsyncRuntime.triggerFromConfig();
|
||||
}
|
||||
@@ -2563,9 +2622,7 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler(
|
||||
);
|
||||
const {
|
||||
setVisibleOverlayVisible: setVisibleOverlayVisibleHandler,
|
||||
setInvisibleOverlayVisible: setInvisibleOverlayVisibleHandler,
|
||||
toggleVisibleOverlay: toggleVisibleOverlayHandler,
|
||||
toggleInvisibleOverlay: toggleInvisibleOverlayHandler,
|
||||
setOverlayVisible: setOverlayVisibleHandler,
|
||||
toggleOverlay: toggleOverlayHandler,
|
||||
} = createOverlayVisibilityRuntime({
|
||||
@@ -2575,29 +2632,8 @@ const {
|
||||
overlayManager.setVisibleOverlayVisible(nextVisible);
|
||||
},
|
||||
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
updateInvisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
|
||||
syncInvisibleOverlayMousePassthrough: () =>
|
||||
overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(),
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () =>
|
||||
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
|
||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||
setMpvSubVisibility: (mpvSubVisible) => {
|
||||
setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible);
|
||||
},
|
||||
},
|
||||
setInvisibleOverlayVisibleDeps: {
|
||||
setInvisibleOverlayVisibleCore,
|
||||
setInvisibleOverlayVisibleState: (nextVisible) => {
|
||||
overlayManager.setInvisibleOverlayVisible(nextVisible);
|
||||
},
|
||||
updateInvisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
|
||||
syncInvisibleOverlayMousePassthrough: () =>
|
||||
overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(),
|
||||
},
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
|
||||
});
|
||||
|
||||
const buildHandleOverlayModalClosedMainDepsHandler =
|
||||
@@ -2657,10 +2693,8 @@ const {
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
},
|
||||
mainDeps: {
|
||||
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
|
||||
focusMainWindow: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
@@ -2671,13 +2705,15 @@ const {
|
||||
onOverlayModalClosed: (modal) => {
|
||||
handleOverlayModalClosed(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal) => {
|
||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => app.quit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
|
||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||
getSubtitleStyle: () => {
|
||||
const resolvedConfig = getResolvedConfig();
|
||||
@@ -2694,9 +2730,6 @@ const {
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
overlayContentMeasurementStore.report(payload);
|
||||
},
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
|
||||
reportHoveredSubtitleToken(tokenIndex);
|
||||
},
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
|
||||
openAnilistSetup: () => openAnilistSetupWindow(),
|
||||
@@ -2750,9 +2783,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
toggleInvisibleOverlay: () => toggleInvisibleOverlay(),
|
||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||
setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => mineSentenceCard(),
|
||||
@@ -2771,7 +2802,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => cycleSecondarySubMode(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
stopApp: () => app.quit(),
|
||||
@@ -2785,40 +2816,29 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
||||
const {
|
||||
createOverlayWindow: createOverlayWindowHandler,
|
||||
createMainWindow: createMainWindowHandler,
|
||||
createInvisibleWindow: createInvisibleWindowHandler,
|
||||
createSecondaryWindow: createSecondaryWindowHandler,
|
||||
createModalWindow: createModalWindowHandler,
|
||||
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({
|
||||
createOverlayWindowDeps: {
|
||||
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
|
||||
isDev,
|
||||
getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled,
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible'
|
||||
? overlayManager.getVisibleOverlayVisible()
|
||||
: windowKind === 'invisible'
|
||||
? overlayManager.getInvisibleOverlayVisible()
|
||||
: false,
|
||||
: false,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
overlayManager.setMainWindow(null);
|
||||
} else if (windowKind === 'invisible') {
|
||||
overlayManager.setInvisibleWindow(null);
|
||||
} else if (windowKind === 'secondary') {
|
||||
overlayManager.setSecondaryWindow(null);
|
||||
} else {
|
||||
overlayManager.setModalWindow(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
setMainWindow: (window) => overlayManager.setMainWindow(window),
|
||||
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
|
||||
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
|
||||
setModalWindow: (window) => overlayManager.setModalWindow(window),
|
||||
});
|
||||
const {
|
||||
@@ -2898,24 +2918,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
appState,
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
updateInvisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
getInitialInvisibleOverlayVisibility: () =>
|
||||
configDerivedRuntime.getInitialInvisibleOverlayVisibility(),
|
||||
createMainWindow: () => createMainWindow(),
|
||||
createInvisibleWindow: () => createInvisibleWindow(),
|
||||
registerGlobalShortcuts: () => registerGlobalShortcuts(),
|
||||
updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry),
|
||||
updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
showDesktopNotification,
|
||||
@@ -2925,9 +2938,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore,
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
overlayManager.setInvisibleOverlayVisible(visible);
|
||||
},
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
@@ -2985,39 +2995,26 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createMainWindow();
|
||||
}
|
||||
|
||||
const invisibleWindow = overlayManager.getInvisibleWindow();
|
||||
if (!invisibleWindow || invisibleWindow.isDestroyed()) {
|
||||
createInvisibleWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
setVisibleOverlayVisibleHandler(visible);
|
||||
}
|
||||
|
||||
function setInvisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
setInvisibleOverlayVisibleHandler(visible);
|
||||
if (visible) {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
toggleVisibleOverlayHandler();
|
||||
}
|
||||
function toggleInvisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
toggleInvisibleOverlayHandler();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
setOverlayVisibleHandler(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function toggleOverlay(): void {
|
||||
toggleOverlayHandler();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
|
||||
handleOverlayModalClosedHandler(modal);
|
||||
@@ -3027,11 +3024,6 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
|
||||
handleMpvCommandFromIpcHandler(command);
|
||||
}
|
||||
|
||||
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
|
||||
appState.hoveredSubtitleTokenIndex = tokenIndex;
|
||||
applyHoveredTokenOverlay();
|
||||
}
|
||||
|
||||
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
||||
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
setInvisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
@@ -74,9 +72,7 @@ function createCliCommandDepsFromContext(
|
||||
isInitialized: context.isOverlayInitialized,
|
||||
initialize: context.initializeOverlay,
|
||||
toggleVisible: context.toggleVisibleOverlay,
|
||||
toggleInvisible: context.toggleInvisibleOverlay,
|
||||
setVisible: context.setVisibleOverlay,
|
||||
setInvisible: context.setInvisibleOverlay,
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: context.copyCurrentSubtitle,
|
||||
|
||||
@@ -53,11 +53,10 @@ export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): Subs
|
||||
}
|
||||
|
||||
export interface MainIpcRuntimeServiceDepsParams {
|
||||
getInvisibleWindow: IpcDepsRuntimeOptions['getInvisibleWindow'];
|
||||
getMainWindow: IpcDepsRuntimeOptions['getMainWindow'];
|
||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||
getInvisibleOverlayVisibility: IpcDepsRuntimeOptions['getInvisibleOverlayVisibility'];
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||
@@ -65,7 +64,6 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
|
||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
|
||||
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
|
||||
@@ -81,7 +79,6 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
|
||||
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
|
||||
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
|
||||
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
|
||||
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
|
||||
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
@@ -132,9 +129,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
|
||||
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
|
||||
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
|
||||
toggleInvisible: CliCommandDepsRuntimeOptions['overlay']['toggleInvisible'];
|
||||
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
|
||||
setInvisible: CliCommandDepsRuntimeOptions['overlay']['setInvisible'];
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle'];
|
||||
@@ -192,18 +187,16 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
params: MainIpcRuntimeServiceDepsParams,
|
||||
): IpcDepsRuntimeOptions {
|
||||
return {
|
||||
getInvisibleWindow: params.getInvisibleWindow,
|
||||
getMainWindow: params.getMainWindow,
|
||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||
getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility,
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
|
||||
getSubtitlePosition: params.getSubtitlePosition,
|
||||
getSubtitleStyle: params.getSubtitleStyle,
|
||||
saveSubtitlePosition: params.saveSubtitlePosition,
|
||||
@@ -220,7 +213,6 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
setRuntimeOption: params.setRuntimeOption,
|
||||
cycleRuntimeOption: params.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: params.reportOverlayContentBounds,
|
||||
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
|
||||
getAnilistStatus: params.getAnilistStatus,
|
||||
clearAnilistToken: params.clearAnilistToken,
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
@@ -279,9 +271,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
isInitialized: params.overlay.isInitialized,
|
||||
initialize: params.overlay.initialize,
|
||||
toggleVisible: params.overlay.toggleVisible,
|
||||
toggleInvisible: params.overlay.toggleInvisible,
|
||||
setVisible: params.overlay.setVisible,
|
||||
setInvisible: params.overlay.setInvisible,
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: params.mining.copyCurrentSubtitle,
|
||||
|
||||
@@ -12,6 +12,7 @@ type MockWindow = {
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
loading: boolean;
|
||||
url: string;
|
||||
loadCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
@@ -19,7 +20,8 @@ function createMockWindow(): MockWindow & {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
isFocused: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
getURL: () => string;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
getShowCount: () => number;
|
||||
getHideCount: () => number;
|
||||
show: () => void;
|
||||
@@ -28,6 +30,7 @@ function createMockWindow(): MockWindow & {
|
||||
webContents: {
|
||||
focused: boolean;
|
||||
isLoading: () => boolean;
|
||||
getURL: () => string;
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
isFocused: () => boolean;
|
||||
once: (event: 'did-finish-load', cb: () => void) => void;
|
||||
@@ -44,14 +47,16 @@ function createMockWindow(): MockWindow & {
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
loading: false,
|
||||
url: 'file:///overlay/index.html?layer=modal',
|
||||
loadCallbacks: [],
|
||||
};
|
||||
return {
|
||||
const window = {
|
||||
...state,
|
||||
isDestroyed: () => state.destroyed,
|
||||
isVisible: () => state.visible,
|
||||
isFocused: () => state.focused,
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
getURL: () => state.url,
|
||||
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
},
|
||||
getShowCount: () => state.showCount,
|
||||
@@ -69,7 +74,8 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
webContents: {
|
||||
isLoading: () => state.loading,
|
||||
send: (channel, payload) => {
|
||||
getURL: () => state.url,
|
||||
send: (channel: string, payload?: unknown) => {
|
||||
if (payload === undefined) {
|
||||
state.sent.push([channel]);
|
||||
return;
|
||||
@@ -78,7 +84,7 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
focused: false,
|
||||
isFocused: () => state.webContentsFocused,
|
||||
once: (_event, cb) => {
|
||||
once: (_event: 'did-finish-load', cb: () => void) => {
|
||||
state.loadCallbacks.push(cb);
|
||||
},
|
||||
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', () => {
|
||||
@@ -93,7 +122,6 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
|
||||
const calls: string[] = [];
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getInvisibleWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
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(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true);
|
||||
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.isFocused(), true);
|
||||
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;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getInvisibleWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => {
|
||||
modalWindow = window;
|
||||
@@ -135,14 +164,47 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
||||
runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(window.getShowCount(), 0);
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
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', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getInvisibleWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const window = createMockWindow();
|
||||
const state: boolean[] = [];
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getInvisibleWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
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: [] }, {
|
||||
restoreOnModalClose: 'subsync',
|
||||
});
|
||||
assert.deepEqual(state, []);
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.deepEqual(state, [true]);
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getInvisibleWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
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(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);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
|
||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
||||
type OverlayHostLayer = 'visible' | 'invisible';
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getInvisibleWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
createModalWindow: () => BrowserWindow | null;
|
||||
getModalGeometry: () => WindowGeometry;
|
||||
@@ -21,6 +21,7 @@ export interface OverlayModalRuntime {
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,8 @@ export function createOverlayModalRuntimeService(
|
||||
): OverlayModalRuntime {
|
||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||
let modalActive = false;
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const notifyModalStateChange = (nextState: boolean): void => {
|
||||
if (modalActive === nextState) return;
|
||||
@@ -53,44 +56,116 @@ export function createOverlayModalRuntimeService(
|
||||
return createdWindow;
|
||||
};
|
||||
|
||||
const getTargetOverlayWindow = (): {
|
||||
window: BrowserWindow;
|
||||
layer: OverlayHostLayer;
|
||||
} | null => {
|
||||
const getTargetOverlayWindow = (): BrowserWindow | null => {
|
||||
const visibleMainWindow = deps.getMainWindow();
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
|
||||
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
|
||||
return { window: visibleMainWindow, layer: 'visible' };
|
||||
return visibleMainWindow;
|
||||
}
|
||||
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
return { window: invisibleWindow, layer: 'invisible' };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const showModalWindow = (window: BrowserWindow): void => {
|
||||
window.show();
|
||||
window.setIgnoreMouseEvents(false);
|
||||
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.webContents.isLoading()) {
|
||||
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();
|
||||
if (!window.webContents.isFocused()) {
|
||||
window.webContents.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
|
||||
if (layer === 'invisible' && typeof window.showInactive === 'function') {
|
||||
window.showInactive();
|
||||
} else {
|
||||
window.show();
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
if (window.isVisible()) {
|
||||
window.setIgnoreMouseEvents(false);
|
||||
if (!window.isFocused()) {
|
||||
window.focus();
|
||||
}
|
||||
if (!window.webContents.isFocused()) {
|
||||
window.webContents.focus();
|
||||
}
|
||||
elevateModalWindow(window);
|
||||
return;
|
||||
}
|
||||
|
||||
showModalWindow(window);
|
||||
};
|
||||
|
||||
const showOverlayWindowForModal = (window: BrowserWindow): void => {
|
||||
window.show();
|
||||
if (!window.isFocused()) {
|
||||
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 = (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
@@ -99,6 +174,7 @@ export function createOverlayModalRuntimeService(
|
||||
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
||||
|
||||
const sendNow = (window: BrowserWindow): void => {
|
||||
ensureModalWindowInteractive(window);
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
@@ -107,55 +183,43 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
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);
|
||||
if (!wasModalActive) {
|
||||
notifyModalStateChange(true);
|
||||
}
|
||||
|
||||
if (!wasVisible) {
|
||||
showModalWindow(modalWindow);
|
||||
} else if (!modalWindow.isFocused()) {
|
||||
showModalWindow(modalWindow);
|
||||
}
|
||||
|
||||
if (modalWindow.webContents.isLoading()) {
|
||||
modalWindow.webContents.once('did-finish-load', () => {
|
||||
if (modalWindow && !modalWindow.isDestroyed() && !modalWindow.webContents.isLoading()) {
|
||||
sendNow(modalWindow);
|
||||
const mainWindow = getTargetOverlayWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
sendOrQueueForWindow(mainWindow, (window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const target = getTargetOverlayWindow();
|
||||
if (!target) return false;
|
||||
|
||||
const { window: targetWindow, layer } = target;
|
||||
const wasVisible = targetWindow.isVisible();
|
||||
const wasVisible = target.isVisible();
|
||||
if (!wasVisible) {
|
||||
showOverlayWindowForModal(targetWindow, layer);
|
||||
showOverlayWindowForModal(target);
|
||||
}
|
||||
|
||||
if (targetWindow.webContents.isLoading()) {
|
||||
targetWindow.webContents.once('did-finish-load', () => {
|
||||
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
|
||||
sendNow(targetWindow);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
sendNow(targetWindow);
|
||||
sendOrQueueForWindow(target, sendNow);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -169,19 +233,44 @@ export function createOverlayModalRuntimeService(
|
||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||
restoreVisibleOverlayOnModalClose.delete(modal);
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (!modalWindow || modalWindow.isDestroyed()) return;
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
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 {
|
||||
sendToActiveOverlayWindow,
|
||||
openRuntimeOptionsPalette,
|
||||
handleOverlayModalClosed,
|
||||
notifyOverlayModalOpened,
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,50 +2,31 @@ import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { BaseWindowTracker } from '../window-trackers';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import {
|
||||
syncInvisibleOverlayMousePassthrough,
|
||||
updateInvisibleOverlayVisibility,
|
||||
updateVisibleOverlayVisibility,
|
||||
} from '../core/services';
|
||||
import { updateVisibleOverlayVisibility } from '../core/services';
|
||||
|
||||
export interface OverlayVisibilityRuntimeDeps {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getInvisibleWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
}
|
||||
|
||||
export interface OverlayVisibilityRuntimeService {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayVisibilityRuntimeService(
|
||||
deps: OverlayVisibilityRuntimeDeps,
|
||||
): 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 {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -59,31 +40,13 @@ export function createOverlayVisibilityRuntimeService(
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
||||
deps.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
});
|
||||
},
|
||||
|
||||
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(),
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
@@ -33,7 +34,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 21);
|
||||
assert.equal(calls.length, 22);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
@@ -58,11 +59,10 @@ test('restore windows on activate recreates windows then syncs visibility', () =
|
||||
const calls: string[] = [];
|
||||
const restore = createRestoreWindowsOnActivateHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
createInvisibleWindow: () => calls.push('invisible'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
|
||||
});
|
||||
|
||||
restore();
|
||||
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
|
||||
assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
@@ -25,6 +26,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyTray();
|
||||
deps.stopConfigHotReload();
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
deps.restoreMpvSubVisibility();
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
@@ -55,14 +57,12 @@ export function createShouldRestoreWindowsOnActivateHandler(deps: {
|
||||
|
||||
export function createRestoreWindowsOnActivateHandler(deps: {
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,14 +19,12 @@ test('restore windows on activate deps builder maps all restoration callbacks',
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
createInvisibleWindow: () => calls.push('invisible'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
|
||||
})();
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']);
|
||||
});
|
||||
|
||||
@@ -10,14 +10,12 @@ export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
|
||||
|
||||
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createMainWindow: () => deps.createMainWindow(),
|
||||
createInvisibleWindow: () => deps.createInvisibleWindow(),
|
||||
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
@@ -72,6 +73,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
@@ -51,6 +52,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
destroyTray: () => deps.destroyTray(),
|
||||
stopConfigHotReload: () => deps.stopConfigHotReload(),
|
||||
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
|
||||
restoreMpvSubVisibility: () =>
|
||||
deps.restoreMpvSubVisibility(),
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
|
||||
@@ -50,26 +50,18 @@ test('initialize overlay runtime main deps map build options and callbacks', ()
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntimeCore: (value) => {
|
||||
calls.push(`core:${JSON.stringify(value)}`);
|
||||
return { invisibleOverlayVisible: true };
|
||||
},
|
||||
buildOptions: () => options,
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
||||
assert.equal(deps.buildOptions(), options);
|
||||
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true });
|
||||
deps.setInvisibleOverlayVisible(true);
|
||||
assert.equal(deps.initializeOverlayRuntimeCore(options), undefined);
|
||||
deps.setOverlayRuntimeInitialized(true);
|
||||
deps.startBackgroundWarmups();
|
||||
assert.deepEqual(calls, [
|
||||
'core:{"id":"opts"}',
|
||||
'set-invisible:true',
|
||||
'set-initialized:true',
|
||||
'warmups',
|
||||
]);
|
||||
assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']);
|
||||
});
|
||||
|
||||
test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
|
||||
@@ -45,9 +45,8 @@ export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean };
|
||||
initializeOverlayRuntimeCore: (options: TOptions) => void;
|
||||
buildOptions: () => TOptions;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}) {
|
||||
@@ -55,7 +54,6 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
|
||||
buildOptions: () => deps.buildOptions(),
|
||||
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) =>
|
||||
deps.setOverlayRuntimeInitialized(initialized),
|
||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||
|
||||
@@ -18,9 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => calls.push('init'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
|
||||
mineSentenceCard: async () => {
|
||||
|
||||
@@ -15,9 +15,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
setInvisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
@@ -60,9 +58,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
setInvisibleOverlay: deps.setInvisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: deps.startPendingMultiCopy,
|
||||
mineSentenceCard: deps.mineSentenceCard,
|
||||
|
||||
@@ -20,9 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
|
||||
mineSentenceCard: async () => {},
|
||||
@@ -73,16 +71,8 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
context.setSocketPath('/tmp/new.sock');
|
||||
context.showOsd('hello');
|
||||
context.setVisibleOverlay(true);
|
||||
context.setInvisibleOverlay(false);
|
||||
context.toggleVisibleOverlay();
|
||||
context.toggleInvisibleOverlay();
|
||||
|
||||
assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'set-visible:true',
|
||||
'set-invisible:false',
|
||||
'toggle-visible',
|
||||
'toggle-invisible',
|
||||
]);
|
||||
assert.deepEqual(calls, ['osd:hello', 'set-visible:true', 'toggle-visible']);
|
||||
});
|
||||
|
||||
@@ -23,9 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
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.initializeOverlay();
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.setInvisibleOverlay(false);
|
||||
deps.printHelp();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'init-overlay',
|
||||
'set-visible:true',
|
||||
'set-invisible:false',
|
||||
'help',
|
||||
]);
|
||||
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
|
||||
|
||||
const retry = await deps.retryAnilistQueueNow();
|
||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||
|
||||
@@ -18,9 +18,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -70,9 +68,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => deps.mineSentenceCard(),
|
||||
|
||||
@@ -24,9 +24,7 @@ function createDeps() {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
toggleInvisibleOverlay: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
setInvisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
|
||||
@@ -20,9 +20,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
setInvisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
@@ -72,9 +70,7 @@ export function createCliCommandContext(
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
setInvisibleOverlay: deps.setInvisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: deps.startPendingMultiCopy,
|
||||
mineSentenceCard: deps.mineSentenceCard,
|
||||
|
||||
@@ -32,10 +32,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
showMpvOsd: () => {},
|
||||
},
|
||||
mainDeps: {
|
||||
getInvisibleWindow: () => null,
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
focusMainWindow: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
@@ -44,7 +42,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => ({}) as never,
|
||||
getSubtitlePosition: () => ({}) as never,
|
||||
getSubtitleStyle: () => ({}) as never,
|
||||
saveSubtitlePosition: () => {},
|
||||
@@ -56,7 +53,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
|
||||
@@ -68,12 +68,14 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
|
||||
@@ -14,7 +14,6 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
registerGlobalShortcutsCore: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
toggleInvisibleOverlay: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
isDev: false,
|
||||
getMainWindow: () => null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user