From 0a2461f45a0b3e2a10013b184b2041c23b9955e5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 22 Feb 2026 18:41:23 -0800 Subject: [PATCH] feat(overlay): split secondary subtitles into dedicated top window --- ...secondary-bar-and-bottom-primary-region.md | 59 +++++++++++++ docs/subagents/INDEX.md | 1 + ...ree-window-layout-20260223T021606Z-9z2t.md | 73 ++++++++++++++++ docs/subagents/collaboration.md | 2 + src/core/services/overlay-manager.test.ts | 43 +++++++++- src/core/services/overlay-manager.ts | 17 ++++ src/core/services/overlay-window-geometry.ts | 41 +++++++++ src/core/services/overlay-window.test.ts | 37 ++++++++ src/core/services/overlay-window.ts | 4 +- src/main.ts | 85 +++++++++++++++++-- .../overlay-window-factory-main-deps.test.ts | 10 ++- .../overlay-window-factory-main-deps.ts | 24 ++++-- .../runtime/overlay-window-factory.test.ts | 16 ++++ src/main/runtime/overlay-window-factory.ts | 13 ++- .../overlay-window-runtime-handlers.test.ts | 8 ++ .../overlay-window-runtime-handlers.ts | 10 +++ src/preload.ts | 4 +- src/renderer/error-recovery.test.ts | 35 ++++++++ src/renderer/error-recovery.ts | 2 +- src/renderer/overlay-content-measurement.ts | 8 ++ src/renderer/style.css | 43 ++++++++++ src/renderer/utils/platform.ts | 15 +++- src/types.ts | 2 +- 23 files changed, 523 insertions(+), 29 deletions(-) create mode 100644 backlog/tasks/task-110 - Split-overlay-into-top-secondary-bar-and-bottom-primary-region.md create mode 100644 docs/subagents/agents/codex-overlay-three-window-layout-20260223T021606Z-9z2t.md create mode 100644 src/core/services/overlay-window-geometry.ts create mode 100644 src/core/services/overlay-window.test.ts diff --git a/backlog/tasks/task-110 - Split-overlay-into-top-secondary-bar-and-bottom-primary-region.md b/backlog/tasks/task-110 - Split-overlay-into-top-secondary-bar-and-bottom-primary-region.md new file mode 100644 index 0000000..f0ad62b --- /dev/null +++ b/backlog/tasks/task-110 - Split-overlay-into-top-secondary-bar-and-bottom-primary-region.md @@ -0,0 +1,59 @@ +--- +id: TASK-110 +title: Split overlay into top secondary bar and bottom primary region +status: Done +assignee: + - codex +created_date: '2026-02-23 02:16' +updated_date: '2026-02-23 02:29' +labels: + - overlay + - subtitle + - ux +dependencies: [] +priority: high +ordinal: 110000 +--- + +## Description + + + +Implement a 3-window overlay layout: + +- Dedicated secondary subtitle window anchored to top of mpv bounds (max 20% of height). +- Visible overlay window constrained to remaining lower region. +- Invisible overlay window constrained to remaining lower region. + +Secondary subtitle bar must stay independently anchored while visible/invisible overlays swap. + + + +## Acceptance Criteria + + + +- [x] #1 Runtime creates/manages a third overlay window dedicated to secondary subtitles. +- [x] #2 Secondary window stays anchored to top region capped at 20% of tracked mpv bounds. +- [x] #3 Visible and invisible overlay windows are constrained to remaining lower region. +- [x] #4 Secondary subtitle rendering/mode updates reach dedicated top window without duplicate top bars in primary windows. +- [x] #5 Focused runtime/core tests cover geometry split + window wiring regressions. + + + +## Implementation Notes + + + +- Added new `secondary` overlay window kind and runtime factory wiring, plus manager ownership (`get/setSecondaryWindow`) and bounds setter. +- Added geometry splitter `splitOverlayGeometryForSecondaryBar` (20% top secondary, 80% bottom primary), integrated into `updateVisibleOverlayBounds` / `updateInvisibleOverlayBounds` flow. +- Main runtime now creates secondary window alongside primary overlays and syncs secondary window visibility with `secondarySubMode`. +- Renderer now recognizes `layer=secondary`; secondary bar is hidden on primary layers, and primary subtitle/modals are hidden on secondary layer. +- Secondary layer no longer reports overlay content measurements to avoid invalid payloads. +- Validation: + - `bun run tsc --noEmit` + - `bun test src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/renderer/error-recovery.test.ts src/core/services/overlay-window.test.ts` + - `bun run build` + - `node --test dist/core/services/overlay-manager.test.js` + + diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 318030d..7cf68a0 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -87,3 +87,4 @@ Read first. Keep concise. | `opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7` | `opencode-task103-jellyfin-main-composer` | `Implement TASK-103 Jellyfin runtime wiring extraction from main.ts into composer module(s), tests, docs, and required validations (no commit).` | `in_progress` | `docs/subagents/agents/opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7.md` | `2026-02-22T22:11:52Z` | | `opencode-task109-discord-presence-20260223T011027Z-j9r4` | `opencode-task109-discord-presence` | `Finalize TASK-109 Discord Rich Presence with plan-first workflow and backlog closure.` | `in_progress` | `docs/subagents/agents/opencode-task109-discord-presence-20260223T011027Z-j9r4.md` | `2026-02-23T01:15:39Z` | | `codex-task88-yomitan-flow-20260223T012755Z-x4m2` | `codex-task88-yomitan-flow` | `Execute TASK-88 remove MeCab fallback tokenizer and simplify Yomitan token flow via plan-first workflow (no commit).` | `handoff` | `docs/subagents/agents/codex-task88-yomitan-flow-20260223T012755Z-x4m2.md` | `2026-02-23T01:44:16Z` | +| `codex-overlay-three-window-layout-20260223T021606Z-9z2t` | `codex-overlay-three-window-layout` | `Implement top-anchored secondary subtitle overlay window (20%) plus swappable primary overlay region (80%).` | `handoff` | `docs/subagents/agents/codex-overlay-three-window-layout-20260223T021606Z-9z2t.md` | `2026-02-23T02:29:51Z` | diff --git a/docs/subagents/agents/codex-overlay-three-window-layout-20260223T021606Z-9z2t.md b/docs/subagents/agents/codex-overlay-three-window-layout-20260223T021606Z-9z2t.md new file mode 100644 index 0000000..7937d68 --- /dev/null +++ b/docs/subagents/agents/codex-overlay-three-window-layout-20260223T021606Z-9z2t.md @@ -0,0 +1,73 @@ +# Agent Session: codex-overlay-three-window-layout-20260223T021606Z-9z2t + +- alias: `codex-overlay-three-window-layout` +- started_utc: `2026-02-23T02:16:06Z` +- status: `handoff` +- mission: `Implement top-anchored secondary subtitle overlay window (20%) plus swappable primary overlay region (80%).` +- linked_backlog: `TASK-110` + +## Intent + +- Convert overlay runtime from 2 fullscreen windows to 3 windows: + - `secondary` window anchored top (max 20% height). + - `visible` / `invisible` windows constrained to remaining 80%. +- Keep secondary subtitle bar independent from visible/invisible swapping. + +## Planned Files + +- `src/core/services/overlay-window.ts` +- `src/core/services/overlay-runtime-init.ts` +- `src/core/services/overlay-manager.ts` +- `src/main.ts` +- `src/preload.ts` +- `src/types.ts` +- `src/renderer/utils/platform.ts` +- `src/renderer/renderer.ts` +- `src/renderer/style.css` +- `src/main/runtime/overlay-window-factory*.ts` +- `src/main/runtime/overlay-window-runtime-handlers*.ts` +- `src/main/runtime/overlay-runtime-options*.ts` +- focused tests under `src/main/runtime/*` + `src/core/services/*` + +## Assumptions + +- Secondary bar window should remain independently present while visible/invisible overlays toggle. +- Top bar height fixed at 20% of tracked mpv bounds (clamped to valid integer px). +- Existing secondary mode semantics (`hidden`/`visible`/`hover`) remain; hover behavior stays functional. + +## Phase Log + +- `2026-02-23T02:16:06Z` plan: read overlay/runtime wiring, implement secondary window + geometry split, run focused tests. +- `2026-02-23T02:24:10Z` implementation: added `secondary` overlay window kind/wiring, overlay-manager secondary window ownership, and geometry split helper (`top 20%` secondary + `bottom 80%` primary). +- `2026-02-23T02:27:40Z` renderer updates: added `secondary` layer detection, disabled measurement reports for secondary layer, and CSS layer split (secondary hidden in primary layers; subtitle/modals hidden in secondary layer). +- `2026-02-23T02:29:51Z` validation: `bun run tsc --noEmit`; `bun test src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/renderer/error-recovery.test.ts src/core/services/overlay-window.test.ts`; `bun run build`; `node --test dist/core/services/overlay-manager.test.js`. + +## Files Touched + +- `src/main.ts` +- `src/core/services/overlay-window.ts` +- `src/core/services/overlay-window-geometry.ts` +- `src/core/services/overlay-window.test.ts` +- `src/core/services/overlay-manager.ts` +- `src/core/services/overlay-manager.test.ts` +- `src/main/runtime/overlay-window-factory.ts` +- `src/main/runtime/overlay-window-factory-main-deps.ts` +- `src/main/runtime/overlay-window-runtime-handlers.ts` +- `src/main/runtime/overlay-window-factory.test.ts` +- `src/main/runtime/overlay-window-factory-main-deps.test.ts` +- `src/main/runtime/overlay-window-runtime-handlers.test.ts` +- `src/preload.ts` +- `src/types.ts` +- `src/renderer/utils/platform.ts` +- `src/renderer/overlay-content-measurement.ts` +- `src/renderer/error-recovery.ts` +- `src/renderer/style.css` +- `backlog/tasks/task-110 - Split-overlay-into-top-secondary-bar-and-bottom-primary-region.md` + +## Handoff + +- Implemented requested 3-window behavior: + - dedicated top secondary overlay window anchored by mpv geometry split, + - visible/invisible overlays constrained to lower region. +- Secondary mode state now syncs window visibility (`hidden` hides window; non-hidden shows + interactive). +- Remaining caution: source-level `overlay-manager.test.ts` still requires dist/node path (Bun ESM + electron named export limitation), but dist test lane passes. diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index 6ae369b..93382df 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -150,3 +150,5 @@ Shared notes. Append-only. - [2026-02-23T01:15:39Z] [opencode-task109-discord-presence-20260223T011027Z-j9r4|opencode-task109-discord-presence] user feedback from real Discord session: status resumed to Playing with noticeable delay; tuned default `discordPresence.updateIntervalMs` from 15000 to 3000 in defaults/docs/examples and updated focused config expectations; reran focused config + discord presence tests green. - [2026-02-23T01:27:55Z] [codex-task88-yomitan-flow-20260223T012755Z-x4m2|codex-task88-yomitan-flow] starting TASK-88 via Backlog MCP + writing-plans/executing-plans; expected overlap in tokenizer modules (`src/core/services/tokenizer*`, Yomitan flow wiring/tests); will keep scope to MeCab fallback removal and token flow simplification. - [2026-02-23T01:44:16Z] [codex-task88-yomitan-flow-20260223T012755Z-x4m2|codex-task88-yomitan-flow] completed TASK-88 implementation pass: removed MeCab fallback branch from `tokenizeSubtitle`, restricted parser-selection to `scanning-parser` candidates, refreshed tokenizer regressions for Yomitan-only flow, updated usage/troubleshooting docs, and verified tokenizer+subtitle suites/build/docs-build green. +- [2026-02-23T02:16:06Z] [codex-overlay-three-window-layout-20260223T021606Z-9z2t|codex-overlay-three-window-layout] overlap note: implementing user-requested 3-window overlay layout (`secondary` top bar + `visible|invisible` bottom region) in `src/core/services/overlay-window.ts`, `src/core/services/overlay-runtime-init.ts`, `src/main.ts`, renderer layer handling/CSS, and focused runtime/core tests. +- [2026-02-23T02:29:51Z] [codex-overlay-three-window-layout-20260223T021606Z-9z2t|codex-overlay-three-window-layout] completed TASK-110 implementation: added `secondary` overlay window kind + manager ownership, enforced top-20%/bottom-80% bounds split in main runtime updates, hid secondary bar in primary renderer layers, and validated via focused tests + tsc + build + dist overlay-manager test. diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index 9a8693b..1a45d52 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -10,6 +10,7 @@ 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.getVisibleOverlayVisible(), false); assert.equal(manager.getInvisibleOverlayVisible(), false); assert.deepEqual(manager.getOverlayWindows(), []); @@ -23,15 +24,20 @@ test('overlay manager stores window references and returns stable window order', const invisibleWindow = { isDestroyed: () => false, } as unknown as Electron.BrowserWindow; + const secondaryWindow = { + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; manager.setMainWindow(visibleWindow); manager.setInvisibleWindow(invisibleWindow); + manager.setSecondaryWindow(secondaryWindow); assert.equal(manager.getMainWindow(), visibleWindow); assert.equal(manager.getInvisibleWindow(), invisibleWindow); + assert.equal(manager.getSecondaryWindow(), secondaryWindow); assert.equal(manager.getOverlayWindow('visible'), visibleWindow); assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow); - assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]); + assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]); }); test('overlay manager excludes destroyed windows', () => { @@ -42,6 +48,9 @@ test('overlay manager excludes destroyed windows', () => { manager.setInvisibleWindow({ isDestroyed: () => false, } as unknown as Electron.BrowserWindow); + manager.setSecondaryWindow({ + isDestroyed: () => true, + } as unknown as Electron.BrowserWindow); assert.equal(manager.getOverlayWindows().length, 1); }); @@ -72,12 +81,24 @@ test('overlay manager broadcasts to non-destroyed windows', () => { 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.broadcastToOverlayWindows('x', 1, 'a'); - assert.deepEqual(calls, [['x', 1, 'a']]); + assert.deepEqual(calls, [ + ['x', 1, 'a'], + ['x', 1, 'a'], + ]); }); test('overlay manager applies bounds by layer', () => { @@ -96,8 +117,15 @@ test('overlay manager applies bounds by layer', () => { invisibleCalls.push(bounds); }, } as unknown as Electron.BrowserWindow; + const secondaryWindow = { + isDestroyed: () => false, + setBounds: (bounds: Electron.Rectangle) => { + invisibleCalls.push(bounds); + }, + } as unknown as Electron.BrowserWindow; manager.setMainWindow(visibleWindow); manager.setInvisibleWindow(invisibleWindow); + manager.setSecondaryWindow(secondaryWindow); manager.setOverlayWindowBounds('visible', { x: 10, @@ -111,9 +139,18 @@ test('overlay manager applies bounds by layer', () => { width: 3, height: 4, }); + manager.setSecondaryWindowBounds({ + x: 8, + y: 9, + width: 10, + height: 11, + }); assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]); - assert.deepEqual(invisibleCalls, [{ x: 1, y: 2, width: 3, height: 4 }]); + assert.deepEqual(invisibleCalls, [ + { x: 1, y: 2, width: 3, height: 4 }, + { x: 8, y: 9, width: 10, height: 11 }, + ]); }); test('runtime-option and debug broadcasts use expected channels', () => { diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts index 670f243..42633c6 100644 --- a/src/core/services/overlay-manager.ts +++ b/src/core/services/overlay-manager.ts @@ -9,8 +9,11 @@ export interface OverlayManager { setMainWindow: (window: BrowserWindow | null) => void; getInvisibleWindow: () => BrowserWindow | null; setInvisibleWindow: (window: BrowserWindow | null) => void; + getSecondaryWindow: () => BrowserWindow | null; + setSecondaryWindow: (window: BrowserWindow | null) => void; getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; + setSecondaryWindowBounds: (geometry: WindowGeometry) => void; getVisibleOverlayVisible: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; getInvisibleOverlayVisible: () => boolean; @@ -22,6 +25,7 @@ export interface OverlayManager { export function createOverlayManager(): OverlayManager { let mainWindow: BrowserWindow | null = null; let invisibleWindow: BrowserWindow | null = null; + let secondaryWindow: BrowserWindow | null = null; let visibleOverlayVisible = false; let invisibleOverlayVisible = false; @@ -34,10 +38,17 @@ export function createOverlayManager(): OverlayManager { setInvisibleWindow: (window) => { invisibleWindow = window; }, + getSecondaryWindow: () => secondaryWindow, + setSecondaryWindow: (window) => { + secondaryWindow = window; + }, getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow), setOverlayWindowBounds: (layer, geometry) => { updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow); }, + setSecondaryWindowBounds: (geometry) => { + updateOverlayWindowBounds(geometry, secondaryWindow); + }, getVisibleOverlayVisible: () => visibleOverlayVisible, setVisibleOverlayVisible: (visible) => { visibleOverlayVisible = visible; @@ -54,6 +65,9 @@ export function createOverlayManager(): OverlayManager { if (invisibleWindow && !invisibleWindow.isDestroyed()) { windows.push(invisibleWindow); } + if (secondaryWindow && !secondaryWindow.isDestroyed()) { + windows.push(secondaryWindow); + } return windows; }, broadcastToOverlayWindows: (channel, ...args) => { @@ -64,6 +78,9 @@ export function createOverlayManager(): OverlayManager { if (invisibleWindow && !invisibleWindow.isDestroyed()) { windows.push(invisibleWindow); } + if (secondaryWindow && !secondaryWindow.isDestroyed()) { + windows.push(secondaryWindow); + } for (const window of windows) { window.webContents.send(channel, ...args); } diff --git a/src/core/services/overlay-window-geometry.ts b/src/core/services/overlay-window-geometry.ts new file mode 100644 index 0000000..328c186 --- /dev/null +++ b/src/core/services/overlay-window-geometry.ts @@ -0,0 +1,41 @@ +import type { WindowGeometry } from '../../types'; + +export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2; + +function toInteger(value: number): number { + return Number.isFinite(value) ? Math.round(value) : 0; +} + +function clampPositive(value: number): number { + return Math.max(1, toInteger(value)); +} + +export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): { + secondary: WindowGeometry; + primary: WindowGeometry; +} { + const x = toInteger(geometry.x); + const y = toInteger(geometry.y); + const width = clampPositive(geometry.width); + const totalHeight = clampPositive(geometry.height); + + const secondaryHeight = clampPositive( + Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)), + ); + const primaryHeight = clampPositive(totalHeight - secondaryHeight); + + return { + secondary: { + x, + y, + width, + height: secondaryHeight, + }, + primary: { + x, + y: y + secondaryHeight, + width, + height: primaryHeight, + }, + }; +} diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts new file mode 100644 index 0000000..31d68af --- /dev/null +++ b/src/core/services/overlay-window.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry'; + +test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => { + const regions = splitOverlayGeometryForSecondaryBar({ + x: 100, + y: 50, + width: 1200, + height: 900, + }); + + assert.deepEqual(regions.secondary, { + x: 100, + y: 50, + width: 1200, + height: 180, + }); + assert.deepEqual(regions.primary, { + x: 100, + y: 230, + width: 1200, + height: 720, + }); +}); + +test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => { + const regions = splitOverlayGeometryForSecondaryBar({ + x: 0, + y: 0, + width: 300, + height: 1, + }); + + assert.ok(regions.secondary.height >= 1); + assert.ok(regions.primary.height >= 1); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 5e8c1a3..3428c11 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -5,7 +5,7 @@ import { createLogger } from '../../logger'; const logger = createLogger('main:overlay-window'); -export type OverlayWindowKind = 'visible' | 'invisible'; +export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; export function updateOverlayWindowBounds( geometry: WindowGeometry, @@ -87,7 +87,7 @@ export function createOverlayWindow( window .loadFile(htmlPath, { - query: { layer: kind === 'visible' ? 'visible' : 'invisible' }, + query: { layer: kind }, }) .catch((err) => { logger.error('Failed to load HTML file:', err); diff --git a/src/main.ts b/src/main.ts index c3e3419..39a5e10 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { nativeImage, Tray, dialog, + screen, } from 'electron'; protocol.registerSchemesAsPrivileged([ @@ -329,6 +330,7 @@ import { 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, @@ -814,6 +816,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp }, setSecondarySubMode: (mode) => { appState.secondarySubMode = mode; + syncSecondaryOverlayWindowVisibility(); }, broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows(channel, payload); @@ -1848,6 +1851,7 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR }, setSecondarySubMode: (mode: SecondarySubMode) => { appState.secondarySubMode = mode; + syncSecondaryOverlayWindowVisibility(); }, defaultSecondarySubMode: 'hover', defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, @@ -2167,10 +2171,57 @@ function updateMpvSubtitleRenderMetrics(patch: Partial updateMpvSubtitleRenderMetricsHandler(patch); } +let lastOverlayWindowGeometry: WindowGeometry | null = null; + +function getOverlayGeometryFallback(): WindowGeometry { + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const bounds = display.workArea; + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; +} + +function getCurrentOverlayGeometry(): WindowGeometry { + if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry; + const trackerGeometry = appState.windowTracker?.getGeometry(); + if (trackerGeometry) return trackerGeometry; + 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 { + lastOverlayWindowGeometry = geometry; + const regions = splitOverlayGeometryForSecondaryBar(geometry); + overlayManager.setOverlayWindowBounds(layer, regions.primary); + overlayManager.setSecondaryWindowBounds(regions.secondary); + syncSecondaryOverlayWindowVisibility(); +} + const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ - setOverlayWindowBounds: (layer, geometry) => - overlayManager.setOverlayWindowBounds(layer, geometry), + setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), }); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( @@ -2179,8 +2230,7 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( const buildUpdateInvisibleOverlayBoundsMainDepsHandler = createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({ - setOverlayWindowBounds: (layer, geometry) => - overlayManager.setOverlayWindowBounds(layer, geometry), + setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), }); const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler(); const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler( @@ -2226,12 +2276,24 @@ async function ensureYomitanExtensionLoaded(): Promise { return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); } -function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow { +function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary'): BrowserWindow { return createOverlayWindowHandler(kind); } +function createSecondaryWindow(): BrowserWindow { + const existingWindow = overlayManager.getSecondaryWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const window = createSecondaryWindowHandler(); + applyOverlayRegions('visible', getCurrentOverlayGeometry()); + return window; +} + function createMainWindow(): BrowserWindow { - return createMainWindowHandler(); + const window = createMainWindowHandler(); + createSecondaryWindow(); + return window; } function createInvisibleWindow(): BrowserWindow { return createInvisibleWindowHandler(); @@ -2339,6 +2401,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ getSecondarySubMode: () => appState.secondarySubMode, setSecondarySubMode: (mode: SecondarySubMode) => { appState.secondarySubMode = mode; + syncSecondaryOverlayWindowVisibility(); }, getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, setLastSecondarySubToggleAtMs: (timestampMs: number) => { @@ -2678,6 +2741,7 @@ const { createOverlayWindow: createOverlayWindowHandler, createMainWindow: createMainWindowHandler, createInvisibleWindow: createInvisibleWindowHandler, + createSecondaryWindow: createSecondaryWindowHandler, } = createOverlayWindowRuntimeHandlers({ createOverlayWindowDeps: { createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), @@ -2689,19 +2753,24 @@ const { isOverlayVisible: (windowKind) => windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() - : overlayManager.getInvisibleOverlayVisible(), + : windowKind === 'invisible' + ? overlayManager.getInvisibleOverlayVisible() + : false, tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { if (windowKind === 'visible') { overlayManager.setMainWindow(null); - } else { + } else if (windowKind === 'invisible') { overlayManager.setInvisibleWindow(null); + } else { + overlayManager.setSecondaryWindow(null); } }, }, setMainWindow: (window) => overlayManager.setMainWindow(window), setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), + setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window), }); const { resolveTrayIconPath: resolveTrayIconPathHandler, diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts index 20e88be..0203cd1 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.test.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -4,6 +4,7 @@ import { createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler, + createBuildCreateSecondaryWindowMainDepsHandler, } from './overlay-window-factory-main-deps'; test('overlay window factory main deps builders return mapped handlers', () => { @@ -39,5 +40,12 @@ test('overlay window factory main deps builders return mapped handlers', () => { const invisibleDeps = buildInvisibleDeps(); invisibleDeps.setInvisibleWindow(null); - assert.deepEqual(calls, ['set-main', 'set-invisible']); + const buildSecondaryDeps = createBuildCreateSecondaryWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'secondary' }), + setSecondaryWindow: () => calls.push('set-secondary'), + }); + const secondaryDeps = buildSecondaryDeps(); + secondaryDeps.setSecondaryWindow(null); + + assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary']); }); diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index a00ed44..fae8f9a 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -1,15 +1,15 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { createOverlayWindowCore: ( - kind: 'visible' | 'invisible', + kind: 'visible' | 'invisible' | 'secondary', options: { isDev: boolean; overlayDebugVisualizationEnabled: boolean; ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean; + isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (windowKind: 'visible' | 'invisible') => void; + onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; }, ) => TWindow; isDev: boolean; @@ -17,9 +17,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { ensureOverlayWindowLevel: (window: TWindow) => void; onRuntimeOptionsChanged: () => void; setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean; + isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (windowKind: 'visible' | 'invisible') => void; + onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; }) { return () => ({ createOverlayWindowCore: deps.createOverlayWindowCore, @@ -35,7 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { } export function createBuildCreateMainWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow; + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; setMainWindow: (window: TWindow | null) => void; }) { return () => ({ @@ -45,7 +45,7 @@ export function createBuildCreateMainWindowMainDepsHandler(deps: { } export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow; + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; setInvisibleWindow: (window: TWindow | null) => void; }) { return () => ({ @@ -53,3 +53,13 @@ export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { setInvisibleWindow: deps.setInvisibleWindow, }); } + +export function createBuildCreateSecondaryWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + setSecondaryWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setSecondaryWindow: deps.setSecondaryWindow, + }); +} diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts index 2fe7d3d..0d1b3e3 100644 --- a/src/main/runtime/overlay-window-factory.test.ts +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -4,6 +4,7 @@ import { createCreateInvisibleWindowHandler, createCreateMainWindowHandler, createCreateOverlayWindowHandler, + createCreateSecondaryWindowHandler, } from './overlay-window-factory'; test('create overlay window handler forwards options and kind', () => { @@ -64,3 +65,18 @@ test('create invisible window handler stores invisible window', () => { assert.equal(createInvisibleWindow(), invisibleWindow); assert.deepEqual(calls, ['create:invisible', 'set:invisible']); }); + +test('create secondary window handler stores secondary window', () => { + const calls: string[] = []; + const secondaryWindow = { id: 'secondary' }; + const createSecondaryWindow = createCreateSecondaryWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return secondaryWindow; + }, + setSecondaryWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createSecondaryWindow(), secondaryWindow); + assert.deepEqual(calls, ['create:secondary', 'set:secondary']); +}); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index 20a1aa3..dcdf639 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -1,4 +1,4 @@ -type OverlayWindowKind = 'visible' | 'invisible'; +type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; export function createCreateOverlayWindowHandler(deps: { createOverlayWindowCore: ( @@ -58,3 +58,14 @@ export function createCreateInvisibleWindowHandler(deps: { return window; }; } + +export function createCreateSecondaryWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setSecondaryWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('secondary'); + deps.setSecondaryWindow(window); + return window; + }; +} diff --git a/src/main/runtime/overlay-window-runtime-handlers.test.ts b/src/main/runtime/overlay-window-runtime-handlers.test.ts index 50de0d3..39943ea 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.test.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.test.ts @@ -5,6 +5,7 @@ import { createOverlayWindowRuntimeHandlers } from './overlay-window-runtime-han test('overlay window runtime handlers compose create/main/invisible handlers', () => { let mainWindow: { kind: string } | null = null; let invisibleWindow: { kind: string } | null = null; + let secondaryWindow: { kind: string } | null = null; let debugEnabled = false; const calls: string[] = []; @@ -28,10 +29,14 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( setInvisibleWindow: (window) => { invisibleWindow = window; }, + setSecondaryWindow: (window) => { + secondaryWindow = window; + }, }); assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' }); assert.deepEqual(runtime.createOverlayWindow('invisible'), { kind: 'invisible' }); + assert.deepEqual(runtime.createOverlayWindow('secondary'), { kind: 'secondary' }); assert.deepEqual(runtime.createMainWindow(), { kind: 'visible' }); assert.deepEqual(mainWindow, { kind: 'visible' }); @@ -39,6 +44,9 @@ test('overlay window runtime handlers compose create/main/invisible handlers', ( assert.deepEqual(runtime.createInvisibleWindow(), { kind: 'invisible' }); assert.deepEqual(invisibleWindow, { kind: 'invisible' }); + assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' }); + assert.deepEqual(secondaryWindow, { kind: 'secondary' }); + assert.equal(debugEnabled, false); assert.deepEqual(calls, []); }); diff --git a/src/main/runtime/overlay-window-runtime-handlers.ts b/src/main/runtime/overlay-window-runtime-handlers.ts index 5fbb980..7e84d5c 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.ts @@ -2,11 +2,13 @@ import { createCreateInvisibleWindowHandler, createCreateMainWindowHandler, createCreateOverlayWindowHandler, + createCreateSecondaryWindowHandler, } from './overlay-window-factory'; import { createBuildCreateInvisibleWindowMainDepsHandler, createBuildCreateMainWindowMainDepsHandler, createBuildCreateOverlayWindowMainDepsHandler, + createBuildCreateSecondaryWindowMainDepsHandler, } from './overlay-window-factory-main-deps'; type CreateOverlayWindowMainDeps = Parameters< @@ -17,6 +19,7 @@ export function createOverlayWindowRuntimeHandlers(deps: { createOverlayWindowDeps: CreateOverlayWindowMainDeps; setMainWindow: (window: TWindow | null) => void; setInvisibleWindow: (window: TWindow | null) => void; + setSecondaryWindow: (window: TWindow | null) => void; }) { const createOverlayWindow = createCreateOverlayWindowHandler( createBuildCreateOverlayWindowMainDepsHandler(deps.createOverlayWindowDeps)(), @@ -33,10 +36,17 @@ export function createOverlayWindowRuntimeHandlers(deps: { setInvisibleWindow: (window) => deps.setInvisibleWindow(window), })(), ); + const createSecondaryWindow = createCreateSecondaryWindowHandler( + createBuildCreateSecondaryWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setSecondaryWindow: (window) => deps.setSecondaryWindow(window), + })(), + ); return { createOverlayWindow, createMainWindow, createInvisibleWindow, + createSecondaryWindow, }; } diff --git a/src/preload.ts b/src/preload.ts index 9e664ca..7c37390 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -55,7 +55,9 @@ import { IPC_CHANNELS } from './shared/ipc/contracts'; const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); const overlayLayer = - overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'invisible' + overlayLayerFromArg === 'visible' || + overlayLayerFromArg === 'invisible' || + overlayLayerFromArg === 'secondary' ? overlayLayerFromArg : null; diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts index 3ac9210..7867aa4 100644 --- a/src/renderer/error-recovery.test.ts +++ b/src/renderer/error-recovery.test.ts @@ -155,3 +155,38 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => { }); } }); + +test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => { + const previousWindow = (globalThis as { window?: unknown }).window; + const previousNavigator = (globalThis as { navigator?: unknown }).navigator; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getOverlayLayer: () => 'secondary', + }, + location: { search: '' }, + }, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + platform: 'MacIntel', + userAgent: 'Mozilla/5.0 (Macintosh)', + }, + }); + + try { + const info = resolvePlatformInfo(); + assert.equal(info.overlayLayer, 'secondary'); + assert.equal(info.isSecondaryLayer, true); + assert.equal(info.shouldToggleMouseIgnore, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: previousNavigator, + }); + } +}); diff --git a/src/renderer/error-recovery.ts b/src/renderer/error-recovery.ts index e24e74a..cffda23 100644 --- a/src/renderer/error-recovery.ts +++ b/src/renderer/error-recovery.ts @@ -17,7 +17,7 @@ export type RendererRecoverySnapshot = { isOverlayInteractive: boolean; isOverSubtitle: boolean; invisiblePositionEditMode: boolean; - overlayLayer: 'visible' | 'invisible'; + overlayLayer: 'visible' | 'invisible' | 'secondary'; }; type NormalizedRendererError = { diff --git a/src/renderer/overlay-content-measurement.ts b/src/renderer/overlay-content-measurement.ts index 9d8db51..086b309 100644 --- a/src/renderer/overlay-content-measurement.ts +++ b/src/renderer/overlay-content-measurement.ts @@ -3,6 +3,10 @@ import type { RendererContext } from './context'; const MEASUREMENT_DEBOUNCE_MS = 80; +function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' { + return layer === 'visible' || layer === 'invisible'; +} + function round2(value: number): number { return Math.round(value * 100) / 100; } @@ -78,6 +82,10 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) { let debounceTimer: number | null = null; function emitNow(): void { + if (!isMeasurableOverlayLayer(ctx.platform.overlayLayer)) { + return; + } + const measurement: OverlayContentMeasurement = { layer: ctx.platform.overlayLayer, measuredAtMs: Date.now(), diff --git a/src/renderer/style.css b/src/renderer/style.css index c7c3b08..76df987 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -533,6 +533,40 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c { pointer-events: auto; } +body.layer-visible #secondarySubContainer, +body.layer-invisible #secondarySubContainer { + display: none !important; + pointer-events: none !important; +} + +body.layer-secondary #subtitleContainer, +body.layer-secondary .modal, +body.layer-secondary .overlay-error-toast { + display: none !important; + pointer-events: none !important; +} + +body.layer-secondary #overlay { + justify-content: flex-start; + align-items: stretch; +} + +body.layer-secondary #secondarySubContainer { + position: absolute; + inset: 0; + top: 0; + left: 0; + right: 0; + transform: none; + max-width: 100%; + border-radius: 0; + background: transparent; + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: center; +} + #secondarySubRoot { text-align: center; font-size: 24px; @@ -548,6 +582,10 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c { cursor: text; } +body.layer-secondary #secondarySubRoot { + max-width: 100%; +} + #secondarySubRoot:empty { display: none; } @@ -591,6 +629,11 @@ body.settings-modal-open #secondarySubContainer { opacity: 1; } +body.layer-secondary #secondarySubContainer.secondary-sub-hover { + padding: 8px 12px; + align-items: center; +} + iframe[id^='yomitan-popup'] { pointer-events: auto !important; z-index: 2147483647 !important; diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts index f328792..72dfb44 100644 --- a/src/renderer/utils/platform.ts +++ b/src/renderer/utils/platform.ts @@ -1,8 +1,9 @@ -export type OverlayLayer = 'visible' | 'invisible'; +export type OverlayLayer = 'visible' | 'invisible' | 'secondary'; export type PlatformInfo = { overlayLayer: OverlayLayer; isInvisibleLayer: boolean; + isSecondaryLayer: boolean; isLinuxPlatform: boolean; isMacOSPlatform: boolean; shouldToggleMouseIgnore: boolean; @@ -15,15 +16,20 @@ export function resolvePlatformInfo(): PlatformInfo { const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); const queryLayer = new URLSearchParams(window.location.search).get('layer'); const overlayLayerFromQuery: OverlayLayer | null = - queryLayer === 'visible' || queryLayer === 'invisible' ? queryLayer : null; + queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary' + ? queryLayer + : null; const overlayLayer: OverlayLayer = overlayLayerFromQuery ?? - (overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'invisible' + (overlayLayerFromPreload === 'visible' || + overlayLayerFromPreload === 'invisible' || + overlayLayerFromPreload === 'secondary' ? overlayLayerFromPreload : 'visible'); const isInvisibleLayer = overlayLayer === 'invisible'; + const isSecondaryLayer = overlayLayer === 'secondary'; const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); const isMacOSPlatform = navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent); @@ -31,9 +37,10 @@ export function resolvePlatformInfo(): PlatformInfo { return { overlayLayer, isInvisibleLayer, + isSecondaryLayer, isLinuxPlatform, isMacOSPlatform, - shouldToggleMouseIgnore: !isLinuxPlatform, + shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer, invisiblePositionEditToggleCode: 'KeyP', invisiblePositionStepPx: 1, invisiblePositionStepFastPx: 4, diff --git a/src/types.ts b/src/types.ts index 09e1e54..7cbdc56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -728,7 +728,7 @@ export interface SubtitleHoverTokenPayload { } export interface ElectronAPI { - getOverlayLayer: () => 'visible' | 'invisible' | null; + getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | null; onSubtitle: (callback: (data: SubtitleData) => void) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;