diff --git a/backlog/tasks/task-285 - Investigate-inconsistent-mpv-y-t-overlay-toggle-after-menu-toggle.md b/backlog/tasks/task-285 - Investigate-inconsistent-mpv-y-t-overlay-toggle-after-menu-toggle.md new file mode 100644 index 00000000..6b344005 --- /dev/null +++ b/backlog/tasks/task-285 - Investigate-inconsistent-mpv-y-t-overlay-toggle-after-menu-toggle.md @@ -0,0 +1,54 @@ +--- +id: TASK-285 +title: Investigate inconsistent mpv y-t overlay toggle after menu toggle +status: To Do +assignee: [] +created_date: '2026-04-07 22:55' +updated_date: '2026-04-07 22:55' +labels: + - bug + - overlay + - keyboard + - mpv +dependencies: [] +references: + - plugin/subminer/process.lua + - plugin/subminer/ui.lua + - src/renderer/handlers/keyboard.ts + - src/main/runtime/autoplay-ready-gate.ts + - src/core/services/overlay-window-input.ts + - backlog/tasks/task-248 - Fix-macOS-visible-overlay-toggle-getting-immediately-restored.md +priority: high +--- + +## Description + + +User report: toggling the visible overlay with mpv `y-t` is inconsistent. After manually toggling through the `y-y` menu, `y-t` may allow one hide, but after toggling back on it can stop hiding the overlay again, forcing the user back into the menu path. + +Initial assessment: + +- no active backlog item currently tracks this exact report +- nearest prior work is `TASK-248`, which fixed a macOS-specific visible-overlay restore bug and is marked done +- current targeted regressions for the old fix surface pass, including plugin ready-signal suppression, focused-overlay `y-t` proxy dispatch, autoplay-ready gate deduplication, and blur-path restacking guards + +This should be treated as a fresh investigation unless reproduction proves it is the same closed macOS issue resurfacing on the current build. + + +## Acceptance Criteria + +- [ ] #1 Reproduce the reported `y-t` / `y-y` inconsistency on the affected platform and identify the exact event sequence +- [ ] #2 Determine whether the failure is in mpv plugin command dispatch, focused-overlay key forwarding, or main-process visible-overlay state transitions +- [ ] #3 Fix the inconsistency so repeated hide/show/hide cycles work from `y-t` without requiring menu recovery +- [ ] #4 Add regression coverage for the reproduced failing sequence +- [ ] #5 Record whether this is a regression of `TASK-248` or a distinct bug + + +## Implementation Plan + + +1. Reproduce the report with platform/build details and capture whether the failing `y-t` press originates in raw mpv or the focused overlay y-chord proxy path. +2. Trace visible-overlay state mutations across plugin toggle commands, autoplay-ready callbacks, and main-process visibility/window blur handling. +3. Patch the narrowest failing path and add regression coverage for the exact hide/show/hide sequence. +4. Re-run targeted plugin, overlay visibility, overlay window, and renderer keyboard suites before broader verification. + diff --git a/changes/fix-windows-overlay-z-order.md b/changes/fix-windows-overlay-z-order.md new file mode 100644 index 00000000..99a13e66 --- /dev/null +++ b/changes/fix-windows-overlay-z-order.md @@ -0,0 +1,10 @@ +type: fixed +area: overlay + +- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus. +- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably. +- Fixed Windows overlay hide/restore behavior so minimizing mpv hides the overlay and restoring mpv brings it back aligned to the tracked window. +- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open. +- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line. +- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears. +- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles. diff --git a/package.json b/package.json index ad2deaa5..21596e99 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "commander": "^14.0.3", "hono": "^4.12.7", "jsonc-parser": "^3.3.1", + "koffi": "^2.15.6", "libsql": "^0.5.22", "ws": "^8.19.0" }, diff --git a/scripts/get-mpv-window-windows.ps1 b/scripts/get-mpv-window-windows.ps1 index 2c5ef794..6eaa27f9 100644 --- a/scripts/get-mpv-window-windows.ps1 +++ b/scripts/get-mpv-window-windows.ps1 @@ -1,7 +1,8 @@ param( - [ValidateSet('geometry')] + [ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner')] [string]$Mode = 'geometry', - [string]$SocketPath + [string]$SocketPath, + [string]$OverlayWindowHandle ) $ErrorActionPreference = 'Stop' @@ -35,19 +36,89 @@ public static class SubMinerWindowsHelper { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetWindowPos( + IntPtr hWnd, + IntPtr hWndInsertAfter, + int X, + int Y, + int cx, + int cy, + uint uFlags + ); + [DllImport("user32.dll", SetLastError = true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + [DllImport("user32.dll", SetLastError = true)] + public static extern int GetWindowLong(IntPtr hWnd, int nIndex); + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd); + + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); } "@ $DWMWA_EXTENDED_FRAME_BOUNDS = 9 + $SWP_NOSIZE = 0x0001 + $SWP_NOMOVE = 0x0002 + $SWP_NOACTIVATE = 0x0010 + $SWP_NOOWNERZORDER = 0x0200 + $SWP_FLAGS = $SWP_NOSIZE -bor $SWP_NOMOVE -bor $SWP_NOACTIVATE -bor $SWP_NOOWNERZORDER + $GWL_EXSTYLE = -20 + $WS_EX_TOPMOST = 0x00000008 + $GWLP_HWNDPARENT = -8 + $HWND_TOP = [IntPtr]::Zero + $HWND_BOTTOM = [IntPtr]::One + $HWND_TOPMOST = [IntPtr](-1) + $HWND_NOTOPMOST = [IntPtr](-2) + + if ($Mode -eq 'foreground-process') { + $foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow() + if ($foregroundWindow -eq [IntPtr]::Zero) { + Write-Output 'not-found' + exit 0 + } + + [uint32]$foregroundProcessId = 0 + [void][SubMinerWindowsHelper]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId) + if ($foregroundProcessId -eq 0) { + Write-Output 'not-found' + exit 0 + } + + try { + $foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction Stop + } catch { + Write-Output 'not-found' + exit 0 + } + + Write-Output "process=$($foregroundProcess.ProcessName)" + exit 0 + } + + if ($Mode -eq 'clear-owner') { + if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) { + [Console]::Error.WriteLine('overlay-window-handle-required') + exit 1 + } + + [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) + [void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero) + Write-Output 'ok' + exit 0 + } function Get-WindowBounds { param([IntPtr]$hWnd) @@ -90,6 +161,7 @@ public static class SubMinerWindowsHelper { } $mpvMatches = New-Object System.Collections.Generic.List[object] + $targetWindowState = 'not-found' $foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow() $callback = [SubMinerWindowsHelper+EnumWindowsProc]{ param([IntPtr]$hWnd, [IntPtr]$lParam) @@ -98,10 +170,6 @@ public static class SubMinerWindowsHelper { return $true } - if ([SubMinerWindowsHelper]::IsIconic($hWnd)) { - return $true - } - [uint32]$windowProcessId = 0 [void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId) if ($windowProcessId -eq 0) { @@ -131,11 +199,22 @@ public static class SubMinerWindowsHelper { } } + if ([SubMinerWindowsHelper]::IsIconic($hWnd)) { + if (-not [string]::IsNullOrWhiteSpace($SocketPath) -and $targetWindowState -ne 'visible') { + $targetWindowState = 'minimized' + } + return $true + } + $bounds = Get-WindowBounds -hWnd $hWnd if ($null -eq $bounds) { return $true } + if (-not [string]::IsNullOrWhiteSpace($SocketPath)) { + $targetWindowState = 'visible' + } + $mpvMatches.Add([PSCustomObject]@{ HWnd = $hWnd X = $bounds.X @@ -151,12 +230,45 @@ public static class SubMinerWindowsHelper { [void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero) + if ($Mode -eq 'lower-overlay') { + if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) { + [Console]::Error.WriteLine('overlay-window-handle-required') + exit 1 + } + + [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) + + [void][SubMinerWindowsHelper]::SetWindowPos( + $overlayWindow, + $HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + $SWP_FLAGS + ) + [void][SubMinerWindowsHelper]::SetWindowPos( + $overlayWindow, + $HWND_BOTTOM, + 0, + 0, + 0, + 0, + $SWP_FLAGS + ) + Write-Output 'ok' + exit 0 + } + $focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1 if ($null -ne $focusedMatch) { [Console]::Error.WriteLine('focus=focused') } else { [Console]::Error.WriteLine('focus=not-focused') } + if (-not [string]::IsNullOrWhiteSpace($SocketPath)) { + [Console]::Error.WriteLine("state=$targetWindowState") + } if ($mpvMatches.Count -eq 0) { Write-Output 'not-found' @@ -168,6 +280,67 @@ public static class SubMinerWindowsHelper { } else { $mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1 } + + if ($Mode -eq 'set-owner') { + if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) { + [Console]::Error.WriteLine('overlay-window-handle-required') + exit 1 + } + + [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) + $targetWindow = [IntPtr]$bestMatch.HWnd + [void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow) + Write-Output 'ok' + exit 0 + } + + if ($Mode -eq 'bind-overlay') { + if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) { + [Console]::Error.WriteLine('overlay-window-handle-required') + exit 1 + } + + [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) + $targetWindow = [IntPtr]$bestMatch.HWnd + $targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE) + $targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0 + + $overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE) + $overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0 + if ($targetWindowIsTopmost -and -not $overlayIsTopmost) { + [void][SubMinerWindowsHelper]::SetWindowPos( + $overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS + ) + } elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) { + [void][SubMinerWindowsHelper]::SetWindowPos( + $overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS + ) + } + + $GW_HWNDPREV = 3 + $windowAboveMpv = [SubMinerWindowsHelper]::GetWindow($targetWindow, $GW_HWNDPREV) + + if ($windowAboveMpv -ne [IntPtr]::Zero -and $windowAboveMpv -eq $overlayWindow) { + Write-Output 'ok' + exit 0 + } + + $insertAfter = $HWND_TOP + if ($windowAboveMpv -ne [IntPtr]::Zero) { + $aboveExStyle = [SubMinerWindowsHelper]::GetWindowLong($windowAboveMpv, $GWL_EXSTYLE) + $aboveIsTopmost = ($aboveExStyle -band $WS_EX_TOPMOST) -ne 0 + if ($aboveIsTopmost -eq $targetWindowIsTopmost) { + $insertAfter = $windowAboveMpv + } + } + + [void][SubMinerWindowsHelper]::SetWindowPos( + $overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS + ) + Write-Output 'ok' + exit 0 + } + Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)" } catch { [Console]::Error.WriteLine($_.Exception.Message) diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 3d0e3f70..2c60d9c8 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -72,6 +72,7 @@ export { createOverlayWindow, enforceOverlayLayerOrder, ensureOverlayWindowLevel, + isOverlayWindowContentReady, syncOverlayWindowLayer, updateOverlayWindowBounds, } from './overlay-window'; diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index 9fd9ad39..42727056 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -443,3 +443,214 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan assert.equal(visibilityRefreshCalls, 2); }); + +test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => { + let subtitleRefreshCalls = 0; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + start: () => {}, + }; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => {}, + refreshCurrentSubtitle: () => { + subtitleRefreshCalls += 1; + }, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => {}, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 }); + + assert.equal(subtitleRefreshCalls, 1); +}); + +test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => { + const calls: string[] = []; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + isTargetWindowMinimized: () => true, + start: () => {}, + }; + const overlayWindows = [ + { + hide: () => calls.push('hide-visible'), + }, + { + hide: () => calls.push('hide-modal'), + }, + ]; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => {}, + refreshCurrentSubtitle: () => {}, + getOverlayWindows: () => overlayWindows as never, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + tracker.onWindowLost?.(); + + assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']); +}); + +test('initializeOverlayRuntime preserves visible overlay on Windows tracker loss when target is not minimized', () => { + const calls: string[] = []; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + isTargetWindowMinimized: () => false, + start: () => {}, + }; + const overlayWindows = [ + { + hide: () => calls.push('hide-visible'), + }, + ]; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => { + calls.push('update-visible'); + }, + refreshCurrentSubtitle: () => {}, + getOverlayWindows: () => overlayWindows as never, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + calls.length = 0; + tracker.onWindowLost?.(); + + assert.deepEqual(calls, ['sync-shortcuts']); +}); + +test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => { + const bounds: Array<{ x: number; y: number; width: number; height: number }> = []; + let visibilityRefreshCalls = 0; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + start: () => {}, + }; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: (geometry) => { + bounds.push(geometry); + }, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => { + visibilityRefreshCalls += 1; + }, + refreshCurrentSubtitle: () => {}, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => {}, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 }; + tracker.onWindowFound?.(restoredGeometry); + + assert.deepEqual(bounds, [restoredGeometry]); + assert.equal(visibilityRefreshCalls, 2); +}); diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 85513fea..c4924df8 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -71,6 +71,7 @@ export function initializeOverlayRuntime(options: { updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; isVisibleOverlayVisible: () => boolean; updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; @@ -78,6 +79,8 @@ export function initializeOverlayRuntime(options: { override?: string | null, targetMpvSocketPath?: string | null, ) => BaseWindowTracker | null; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }): void { options.createMainWindow(); options.registerGlobalShortcuts(); @@ -94,11 +97,23 @@ export function initializeOverlayRuntime(options: { }; windowTracker.onWindowFound = (geometry: WindowGeometry) => { options.updateVisibleOverlayBounds(geometry); + options.bindOverlayOwner?.(); if (options.isVisibleOverlayVisible()) { options.updateVisibleOverlayVisibility(); + options.refreshCurrentSubtitle?.(); } }; windowTracker.onWindowLost = () => { + options.releaseOverlayOwner?.(); + if ( + process.platform === 'win32' && + typeof windowTracker.isTargetWindowMinimized === 'function' && + !windowTracker.isTargetWindowMinimized() + ) { + options.syncOverlayShortcuts(); + return; + } + for (const window of options.getOverlayWindows()) { window.hide(); } diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index f405b3cf..eeefbb52 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -6,31 +6,59 @@ import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './over type WindowTrackerStub = { isTracking: () => boolean; getGeometry: () => { x: number; y: number; width: number; height: number } | null; + isTargetWindowFocused?: () => boolean; + isTargetWindowMinimized?: () => boolean; }; function createMainWindowRecorder() { const calls: string[] = []; let visible = false; + let focused = false; + let opacity = 1; const window = { isDestroyed: () => false, isVisible: () => visible, + isFocused: () => focused, hide: () => { visible = false; + focused = false; calls.push('hide'); }, show: () => { visible = true; calls.push('show'); }, + showInactive: () => { + visible = true; + calls.push('show-inactive'); + }, focus: () => { + focused = true; calls.push('focus'); }, + setAlwaysOnTop: (flag: boolean) => { + calls.push(`always-on-top:${flag}`); + }, setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`); }, + setOpacity: (nextOpacity: number) => { + opacity = nextOpacity; + calls.push(`opacity:${nextOpacity}`); + }, + moveTop: () => { + calls.push('move-top'); + }, }; - return { window, calls }; + return { + window, + calls, + getOpacity: () => opacity, + setFocused: (nextFocused: boolean) => { + focused = nextFocused; + }, + }; } test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => { @@ -167,7 +195,7 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke assert.ok(!calls.includes('osd')); }); -test('Windows visible overlay stays click-through and does not steal focus while tracked', () => { +test('Windows visible overlay stays click-through and binds to mpv while tracked', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => true, @@ -186,6 +214,9 @@ test('Windows visible overlay stays click-through and does not steal focus while ensureOverlayWindowLevel: () => { calls.push('ensure-level'); }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, syncPrimaryOverlayWindowLayer: () => { calls.push('sync-layer'); }, @@ -199,12 +230,58 @@ test('Windows visible overlay stays click-through and does not steal focus while isWindowsPlatform: true, } as never); + assert.ok(calls.includes('opacity:0')); assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('show')); + assert.ok(calls.includes('show-inactive')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('move-top')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('focus')); }); -test('tracked Windows overlay refresh preserves renderer-managed mouse interaction when already visible', () => { +test('Windows visible overlay restores opacity after the deferred reveal delay', async () => { + const { window, calls, getOpacity } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.equal(getOpacity(), 0); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(getOpacity(), 1); + assert.ok(calls.includes('opacity:1')); +}); + +test('tracked Windows overlay refresh rebinds while already visible', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => true, @@ -223,6 +300,9 @@ test('tracked Windows overlay refresh preserves renderer-managed mouse interacti ensureOverlayWindowLevel: () => { calls.push('ensure-level'); }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, syncPrimaryOverlayWindowLayer: () => { calls.push('sync-layer'); }, @@ -250,6 +330,9 @@ test('tracked Windows overlay refresh preserves renderer-managed mouse interacti ensureOverlayWindowLevel: () => { calls.push('ensure-level'); }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, syncPrimaryOverlayWindowLayer: () => { calls.push('sync-layer'); }, @@ -263,9 +346,11 @@ test('tracked Windows overlay refresh preserves renderer-managed mouse interacti isWindowsPlatform: true, } as never); - assert.ok(!calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('show')); - assert.ok(calls.includes('ensure-level')); + assert.ok(!calls.includes('ensure-level')); assert.ok(calls.includes('sync-shortcuts')); }); @@ -288,6 +373,9 @@ test('forced passthrough still reapplies while visible on Windows', () => { ensureOverlayWindowLevel: () => { calls.push('ensure-level'); }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, syncPrimaryOverlayWindowLayer: () => { calls.push('sync-layer'); }, @@ -315,6 +403,9 @@ test('forced passthrough still reapplies while visible on Windows', () => { ensureOverlayWindowLevel: () => { calls.push('ensure-level'); }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, syncPrimaryOverlayWindowLayer: () => { calls.push('sync-layer'); }, @@ -330,6 +421,286 @@ test('forced passthrough still reapplies while visible on Windows', () => { } as never); assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); +}); + +test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + forceMousePassthrough: true, + } as never); + + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('sync-windows-z-order')); +}); + +test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + 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, + isWindowsPlatform: false, + forceMousePassthrough: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('always-on-top:false')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); +}); + +test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => { + const { window, calls } = createMainWindowRecorder(); + let focused = true; + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => focused, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + focused = false; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); + assert.ok(!calls.includes('show')); +}); + +test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + setFocused(true); + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('move-top')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); +}); + +test('tracked Windows overlay binds above mpv even when tracker focus lags', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); }); test('visible overlay stays hidden while a modal window is active', () => { @@ -487,6 +858,157 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => { assert.ok(!calls.includes('update-bounds')); }); +test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => { + const { window, calls } = createMainWindowRecorder(); + let tracking = true; + const tracker: WindowTrackerStub = { + isTracking: () => tracking, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + tracking = false; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(!calls.includes('hide')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(calls.includes('sync-shortcuts')); +}); + +test('Windows hides the visible overlay when the tracked window is minimized', () => { + const { window, calls } = createMainWindowRecorder(); + let tracking = true; + const tracker: WindowTrackerStub = { + isTracking: () => tracking, + getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null), + isTargetWindowMinimized: () => !tracking, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + tracking = false; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('hide')); + assert.ok(!calls.includes('sync-windows-z-order')); +}); + test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => { const { window, calls } = createMainWindowRecorder(); let trackerWarning = false; diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 9cb17ead..08564f69 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -2,16 +2,63 @@ import type { BrowserWindow } from 'electron'; import { BaseWindowTracker } from '../../window-trackers'; import { WindowGeometry } from '../../types'; +const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48; +const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap< + BrowserWindow, + ReturnType +>(); +const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; + +function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void { + const opacityCapableWindow = window as BrowserWindow & { + setOpacity?: (opacity: number) => void; + }; + opacityCapableWindow.setOpacity?.(opacity); +} + +function clearPendingWindowsOverlayReveal(window: BrowserWindow): void { + const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window); + if (!pendingTimeout) { + return; + } + clearTimeout(pendingTimeout); + pendingWindowsOverlayRevealTimeoutByWindow.delete(window); +} + +function scheduleWindowsOverlayReveal(window: BrowserWindow): void { + clearPendingWindowsOverlayReveal(window); + const timeout = setTimeout(() => { + pendingWindowsOverlayRevealTimeoutByWindow.delete(window); + if (window.isDestroyed() || !window.isVisible()) { + return; + } + setOverlayWindowOpacity(window, 1); + }, WINDOWS_OVERLAY_REVEAL_DELAY_MS); + pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout); +} + +function isOverlayWindowContentReady(window: BrowserWindow): boolean { + return ( + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] === true + ); +} + export function updateVisibleOverlayVisibility(args: { visibleOverlayVisible: boolean; modalActive?: boolean; forceMousePassthrough?: boolean; mainWindow: BrowserWindow | null; windowTracker: BaseWindowTracker | null; + lastKnownWindowsForegroundProcessName?: string | null; + windowsOverlayProcessName?: string | null; + windowsFocusHandoffGraceActive?: boolean; trackerNotReadyWarningShown: boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void; + syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void; syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; enforceOverlayLayerOrder: () => void; syncOverlayShortcuts: () => void; @@ -30,6 +77,10 @@ export function updateVisibleOverlayVisibility(args: { const mainWindow = args.mainWindow; if (args.modalActive) { + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); return; @@ -39,19 +90,86 @@ export function updateVisibleOverlayVisibility(args: { const forceMousePassthrough = args.forceMousePassthrough === true; const shouldDefaultToPassthrough = args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough; + const isVisibleOverlayFocused = + typeof mainWindow.isFocused === 'function' && mainWindow.isFocused(); + const windowsForegroundProcessName = + args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null; + const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null; + const hasWindowsForegroundProcessSignal = + args.isWindowsPlatform && windowsForegroundProcessName !== null; + const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true; + const isTrackedWindowsTargetMinimized = + args.isWindowsPlatform && + typeof args.windowTracker?.isTargetWindowMinimized === 'function' && + args.windowTracker.isTargetWindowMinimized(); + const shouldPreserveWindowsOverlayDuringFocusHandoff = + args.isWindowsPlatform && + args.windowsFocusHandoffGraceActive === true && + !!args.windowTracker && + (!hasWindowsForegroundProcessSignal || + windowsForegroundProcessName === 'mpv' || + (windowsOverlayProcessName !== null && + windowsForegroundProcessName === windowsOverlayProcessName)) && + !isTrackedWindowsTargetMinimized && + (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); + const shouldIgnoreMouseEvents = + forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused); + const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; + const shouldKeepTrackedWindowsOverlayTopmost = + !args.isWindowsPlatform || + !args.windowTracker || + isVisibleOverlayFocused || + isTrackedWindowsTargetFocused || + shouldPreserveWindowsOverlayDuringFocusHandoff || + (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); const wasVisible = mainWindow.isVisible(); - if (!wasVisible || forceMousePassthrough) { - if (shouldDefaultToPassthrough) { + if (shouldIgnoreMouseEvents) { + mainWindow.setIgnoreMouseEvents(true, { forward: true }); + } else { + mainWindow.setIgnoreMouseEvents(false); + } + + if (shouldBindTrackedWindowsOverlay) { + // On Windows, z-order is enforced by the OS via the owner window mechanism + // (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv + // without any manual z-order management. + } else if (!forceMousePassthrough) { + args.ensureOverlayWindowLevel(mainWindow); + } else { + mainWindow.setAlwaysOnTop(false); + } + if (!wasVisible) { + const hasWebContents = + typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object'; + if ( + args.isWindowsPlatform && + hasWebContents && + !isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow) + ) { + // skip — ready-to-show hasn't fired yet; the onWindowContentReady + // callback will trigger another visibility update when the renderer + // has painted its first frame. + } else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) { + setOverlayWindowOpacity(mainWindow, 0); + mainWindow.showInactive(); mainWindow.setIgnoreMouseEvents(true, { forward: true }); + scheduleWindowsOverlayReveal(mainWindow); } else { - mainWindow.setIgnoreMouseEvents(false); + if (args.isWindowsPlatform) { + setOverlayWindowOpacity(mainWindow, 0); + } + mainWindow.show(); + if (args.isWindowsPlatform) { + scheduleWindowsOverlayReveal(mainWindow); + } } } - args.ensureOverlayWindowLevel(mainWindow); - if (!wasVisible) { - mainWindow.show(); + + if (shouldBindTrackedWindowsOverlay) { + args.syncWindowsOverlayToMpvZOrder?.(mainWindow); } + if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { mainWindow.focus(); } @@ -71,6 +189,10 @@ export function updateVisibleOverlayVisibility(args: { if (!args.visibleOverlayVisible) { args.setTrackerNotReadyWarningShown(false); args.resetOverlayLoadingOsdSuppression?.(); + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); return; @@ -84,7 +206,9 @@ export function updateVisibleOverlayVisibility(args: { } args.syncPrimaryOverlayWindowLayer('visible'); showPassiveVisibleOverlay(); - args.enforceOverlayLayerOrder(); + if (!args.forceMousePassthrough && !args.isWindowsPlatform) { + args.enforceOverlayLayerOrder(); + } args.syncOverlayShortcuts(); return; } @@ -95,6 +219,10 @@ export function updateVisibleOverlayVisibility(args: { args.setTrackerNotReadyWarningShown(true); maybeShowOverlayLoadingOsd(); } + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); return; @@ -107,11 +235,32 @@ export function updateVisibleOverlayVisibility(args: { return; } + if ( + args.isWindowsPlatform && + typeof args.windowTracker.isTargetWindowMinimized === 'function' && + !args.windowTracker.isTargetWindowMinimized() && + (mainWindow.isVisible() || args.windowTracker.getGeometry() !== null) + ) { + args.setTrackerNotReadyWarningShown(false); + const geometry = args.windowTracker.getGeometry(); + if (geometry) { + args.updateVisibleOverlayBounds(geometry); + } + args.syncPrimaryOverlayWindowLayer('visible'); + showPassiveVisibleOverlay(); + args.syncOverlayShortcuts(); + return; + } + if (!args.trackerNotReadyWarningShown) { args.setTrackerNotReadyWarningShown(true); maybeShowOverlayLoadingOsd(); } + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); } diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index 5b2a4674..6d275e41 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -8,7 +8,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com yomitanSession: null, }); + assert.equal(options.backgroundColor, '#00000000'); assert.equal(options.webPreferences?.sandbox, false); + assert.equal(options.webPreferences?.backgroundThrottling, false); +}); + +test('Windows visible overlay window config does not start as always-on-top', () => { + const originalPlatform = process.platform; + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }); + + try { + const options = buildOverlayWindowOptions('visible', { + isDev: false, + yomitanSession: null, + }); + + assert.equal(options.alwaysOnTop, false); + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform, + }); + } }); test('overlay window config uses the provided Yomitan session when available', () => { diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts index 0ad8be5d..44f0ab59 100644 --- a/src/core/services/overlay-window-input.ts +++ b/src/core/services/overlay-window-input.ts @@ -66,7 +66,14 @@ export function handleOverlayWindowBlurred(options: { isOverlayVisible: (kind: OverlayWindowKind) => boolean; ensureOverlayWindowLevel: () => void; moveWindowTop: () => void; + onWindowsVisibleOverlayBlur?: () => void; + platform?: NodeJS.Platform; }): boolean { + if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') { + options.onWindowsVisibleOverlayBlur?.(); + return false; + } + if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) { return false; } diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts index 80619b25..146373a3 100644 --- a/src/core/services/overlay-window-options.ts +++ b/src/core/services/overlay-window-options.ts @@ -10,6 +10,7 @@ export function buildOverlayWindowOptions( }, ): BrowserWindowConstructorOptions { const showNativeDebugFrame = process.platform === 'win32' && options.isDev; + const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible'); return { show: false, @@ -18,8 +19,9 @@ export function buildOverlayWindowOptions( x: 0, y: 0, transparent: true, + backgroundColor: '#00000000', frame: false, - alwaysOnTop: true, + alwaysOnTop: shouldStartAlwaysOnTop, skipTaskbar: true, resizable: false, hasShadow: false, @@ -31,6 +33,7 @@ export function buildOverlayWindowOptions( contextIsolation: true, nodeIntegration: false, sandbox: false, + backgroundThrottling: false, webSecurity: true, session: options.yomitanSession ?? undefined, additionalArguments: [`--overlay-layer=${kind}`], diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 42e8a77a..4695a52d 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -103,6 +103,49 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h assert.deepEqual(calls, []); }); +test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => { + const calls: string[] = []; + + const handled = handleOverlayWindowBlurred({ + kind: 'visible', + windowVisible: true, + isOverlayVisible: () => true, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + moveWindowTop: () => { + calls.push('move-top'); + }, + platform: 'win32', + }); + + assert.equal(handled, false); + assert.deepEqual(calls, []); +}); + +test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => { + const calls: string[] = []; + + const handled = handleOverlayWindowBlurred({ + kind: 'visible', + windowVisible: true, + isOverlayVisible: () => true, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + moveWindowTop: () => { + calls.push('move-top'); + }, + onWindowsVisibleOverlayBlur: () => { + calls.push('windows-visible-blur'); + }, + platform: 'win32', + }); + + assert.equal(handled, false); + assert.deepEqual(calls, ['windows-visible-blur']); +}); + test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { const calls: string[] = []; @@ -117,6 +160,7 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking' moveWindowTop: () => { calls.push('move-visible'); }, + platform: 'linux', }), true, ); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 6b7b4c6f..bc3a94fc 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -13,6 +13,17 @@ import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds const logger = createLogger('main:overlay-window'); const overlayWindowLayerByInstance = new WeakMap(); +const overlayWindowContentReady = new WeakSet(); +const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; + +export function isOverlayWindowContentReady(window: BrowserWindow): boolean { + return ( + overlayWindowContentReady.has(window) || + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] === true + ); +} function getOverlayWindowHtmlPath(): string { return path.join(__dirname, '..', '..', 'renderer', 'index.html'); @@ -76,13 +87,17 @@ export function createOverlayWindow( isOverlayVisible: (kind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (kind: OverlayWindowKind) => void; yomitanSession?: Session | null; }, ): BrowserWindow { const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); - options.ensureOverlayWindowLevel(window); + if (!(process.platform === 'win32' && kind === 'visible')) { + options.ensureOverlayWindowLevel(window); + } loadOverlayWindowLayer(window, kind); window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { @@ -93,6 +108,14 @@ export function createOverlayWindow( options.onRuntimeOptionsChanged(); }); + window.once('ready-to-show', () => { + overlayWindowContentReady.add(window); + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] = true; + options.onWindowContentReady?.(); + }); + if (kind === 'visible') { window.webContents.on('devtools-opened', () => { options.setOverlayDebugVisualizationEnabled(true); @@ -136,6 +159,8 @@ export function createOverlayWindow( moveWindowTop: () => { window.moveTop(); }, + onWindowsVisibleOverlayBlur: + kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined, }); }); diff --git a/src/main.ts b/src/main.ts index 78827c51..2583c8cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -130,6 +130,14 @@ import { type LogLevelSource, } from './logger'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; +import { + clearWindowsOverlayOwnerNative, + ensureWindowsOverlayTransparencyNative, + getWindowsForegroundProcessNameNative, + queryWindowsForegroundProcessName, + setWindowsOverlayOwnerNative, + syncWindowsOverlayToMpvZOrder, +} from './window-trackers/windows-helper'; import { commandNeedsOverlayStartupPrereqs, commandNeedsOverlayRuntime, @@ -1835,6 +1843,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getForceMousePassthrough: () => appState.statsOverlayVisible, getWindowTracker: () => appState.windowTracker, + getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, + getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), + getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; @@ -1843,6 +1854,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel(window); }, + syncWindowsOverlayToMpvZOrder: (_window) => { + requestWindowsVisibleOverlayZOrderSync(); + }, syncPrimaryOverlayWindowLayer: (layer) => { syncPrimaryOverlayWindowLayer(layer); }, @@ -1870,6 +1884,187 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( }, })(), ); + +const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; +const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; +const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; +let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; +let windowsVisibleOverlayZOrderSyncInFlight = false; +let windowsVisibleOverlayZOrderSyncQueued = false; +let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; +let windowsVisibleOverlayForegroundPollInFlight = false; +let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; +let lastWindowsVisibleOverlayBlurredAtMs = 0; + +function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { + for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { + clearTimeout(timeout); + } + windowsVisibleOverlayBlurRefreshTimeouts = []; +} + +function getWindowsNativeWindowHandle(window: BrowserWindow): string { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 + ? handle.readBigUInt64LE(0).toString() + : BigInt(handle.readUInt32LE(0)).toString(); +} + +function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 + ? Number(handle.readBigUInt64LE(0)) + : handle.readUInt32LE(0); +} + +async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { + if (process.platform !== 'win32') { + return false; + } + + const mainWindow = overlayManager.getMainWindow(); + if ( + !mainWindow || + mainWindow.isDestroyed() || + !mainWindow.isVisible() || + !overlayManager.getVisibleOverlayVisible() + ) { + return false; + } + + const windowTracker = appState.windowTracker; + if (!windowTracker) { + return false; + } + + if ( + typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized() + ) { + return false; + } + + if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) { + return false; + } + + return await syncWindowsOverlayToMpvZOrder({ + overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow), + targetMpvSocketPath: appState.mpvSocketPath, + }); +} + +function requestWindowsVisibleOverlayZOrderSync(): void { + if (process.platform !== 'win32') { + return; + } + + if (windowsVisibleOverlayZOrderSyncInFlight) { + windowsVisibleOverlayZOrderSyncQueued = true; + return; + } + + windowsVisibleOverlayZOrderSyncInFlight = true; + void syncWindowsVisibleOverlayToMpvZOrder() + .catch((error) => { + logger.warn('Failed to bind Windows overlay z-order to mpv', error); + }) + .finally(() => { + windowsVisibleOverlayZOrderSyncInFlight = false; + if (!windowsVisibleOverlayZOrderSyncQueued) { + return; + } + + windowsVisibleOverlayZOrderSyncQueued = false; + requestWindowsVisibleOverlayZOrderSync(); + }); +} + +function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { + return ( + process.platform === 'win32' && + lastWindowsVisibleOverlayBlurredAtMs > 0 && + Date.now() - lastWindowsVisibleOverlayBlurredAtMs <= + WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS + ); +} + +function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean { + if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + return false; + } + + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return false; + } + + const windowTracker = appState.windowTracker; + if (!windowTracker) { + return false; + } + + if ( + typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized() + ) { + return false; + } + + const overlayFocused = mainWindow.isFocused(); + const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false; + return !overlayFocused && !trackerFocused; +} + +function maybePollWindowsVisibleOverlayForegroundProcess(): void { + if (!shouldPollWindowsVisibleOverlayForegroundProcess()) { + lastWindowsVisibleOverlayForegroundProcessName = null; + return; + } + + const processName = getWindowsForegroundProcessNameNative(); + const normalizedProcessName = processName?.trim().toLowerCase() ?? null; + const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName; + lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName; + + if (normalizedProcessName !== previousProcessName) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') { + requestWindowsVisibleOverlayZOrderSync(); + } +} + +function ensureWindowsVisibleOverlayForegroundPollLoop(): void { + if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) { + return; + } + + windowsVisibleOverlayForegroundPollInterval = setInterval(() => { + maybePollWindowsVisibleOverlayForegroundProcess(); + }, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS); +} + +function scheduleVisibleOverlayBlurRefresh(): void { + if (process.platform !== 'win32') { + return; + } + + lastWindowsVisibleOverlayBlurredAtMs = Date.now(); + clearWindowsVisibleOverlayBlurRefreshTimeouts(); + for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { + const refreshTimeout = setTimeout(() => { + windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter( + (timeout) => timeout !== refreshTimeout, + ); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, delayMs); + windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout); + } +} + +ensureWindowsVisibleOverlayForegroundPollLoop(); + const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, @@ -3796,7 +3991,14 @@ function createModalWindow(): BrowserWindow { } function createMainWindow(): BrowserWindow { - return createMainWindowHandler(); + const window = createMainWindowHandler(); + if (process.platform === 'win32') { + const overlayHwnd = getWindowsNativeWindowHandleNumber(window); + if (!ensureWindowsOverlayTransparencyNative(overlayHwnd)) { + logger.warn('Failed to eagerly extend Windows overlay transparency via koffi'); + } + } + return window; } function ensureTray(): void { @@ -4595,6 +4797,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), + onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), + onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), onWindowClosed: (windowKind) => { if (windowKind === 'visible') { overlayManager.setMainWindow(null); @@ -4696,6 +4900,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), }, + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, overlayShortcutsRuntime: { syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), }, @@ -4719,6 +4926,36 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = }, updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), + bindOverlayOwner: () => { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const tracker = appState.windowTracker; + const mpvResult = tracker + ? (() => { + try { + const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32'); + const poll = win32.findMpvWindows(); + const focused = poll.matches.find((m) => m.isForeground); + return focused ?? poll.matches.sort((a, b) => b.area - a.area)[0] ?? null; + } catch { + return null; + } + })() + : null; + if (!mpvResult) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) { + logger.warn('Failed to set overlay owner via koffi'); + } + }, + releaseOverlayOwner: () => { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (!clearWindowsOverlayOwnerNative(overlayHwnd)) { + logger.warn('Failed to clear overlay owner via koffi'); + } + }, getOverlayWindows: () => getOverlayWindows(), getResolvedConfig: () => getResolvedConfig(), showDesktopNotification, diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 45b59d8e..fff9b868 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps { getVisibleOverlayVisible: () => boolean; getForceMousePassthrough: () => boolean; getWindowTracker: () => BaseWindowTracker | null; + getLastKnownWindowsForegroundProcessName?: () => string | null; + getWindowsOverlayProcessName?: () => string | null; + getWindowsFocusHandoffGraceActive?: () => boolean; getTrackerNotReadyWarningShown: () => boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void; + syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void; syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; enforceOverlayLayerOrder: () => void; syncOverlayShortcuts: () => void; @@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService( return { updateVisibleOverlayVisibility(): void { + const visibleOverlayVisible = deps.getVisibleOverlayVisible(); + const forceMousePassthrough = deps.getForceMousePassthrough(); + const windowTracker = deps.getWindowTracker(); + const mainWindow = deps.getMainWindow(); + updateVisibleOverlayVisibility({ - visibleOverlayVisible: deps.getVisibleOverlayVisible(), + visibleOverlayVisible, modalActive: deps.getModalActive(), - forceMousePassthrough: deps.getForceMousePassthrough(), - mainWindow: deps.getMainWindow(), - windowTracker: deps.getWindowTracker(), + forceMousePassthrough, + mainWindow, + windowTracker, + lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), + windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null, + windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false, trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => { deps.setTrackerNotReadyWarningShown(shown); @@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService( updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) => + deps.syncWindowsOverlayToMpvZOrder?.(window), syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts index cdc6832e..287fba85 100644 --- a/src/main/runtime/overlay-runtime-bootstrap.ts +++ b/src/main/runtime/overlay-runtime-bootstrap.ts @@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: { ) => Promise; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }) => void; export function createInitializeOverlayRuntimeHandler(deps: { diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts index c243e13b..83f7f895 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.test.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts @@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => overlayVisibilityRuntime: { updateVisibleOverlayVisibility: () => calls.push('update-visible'), }, + refreshCurrentSubtitle: () => calls.push('refresh-subtitle'), overlayShortcutsRuntime: { syncOverlayShortcuts: () => calls.push('sync-shortcuts'), }, @@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => deps.registerGlobalShortcuts(); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); deps.updateVisibleOverlayVisibility(); + deps.refreshCurrentSubtitle?.(); deps.syncOverlayShortcuts(); deps.showDesktopNotification('title', {}); @@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => 'register-shortcuts', 'visible-bounds', 'update-visible', + 'refresh-subtitle', 'sync-shortcuts', 'notify', ]); diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index 3022e066..122d0044 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { overlayVisibilityRuntime: { updateVisibleOverlayVisibility: () => void; }; + refreshCurrentSubtitle?: () => void; overlayShortcutsRuntime: { syncOverlayShortcuts: () => void; }; @@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }) { return (): OverlayRuntimeOptionsMainDeps => ({ getBackendOverride: () => deps.appState.backendOverride, @@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), updateVisibleOverlayVisibility: () => deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), getOverlayWindows: () => deps.getOverlayWindows(), syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), setWindowTracker: (tracker) => { @@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), + bindOverlayOwner: deps.bindOverlayOwner, + releaseOverlayOwner: deps.releaseOverlayOwner, }); } diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts index b3f20e87..90a35960 100644 --- a/src/main/runtime/overlay-runtime-options.test.ts +++ b/src/main/runtime/overlay-runtime-options.test.ts @@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => { updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'), isVisibleOverlayVisible: () => true, updateVisibleOverlayVisibility: () => calls.push('update-visible'), + refreshCurrentSubtitle: () => calls.push('refresh-subtitle'), getOverlayWindows: () => [], syncOverlayShortcuts: () => calls.push('sync-shortcuts'), setWindowTracker: () => calls.push('set-tracker'), @@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => { options.registerGlobalShortcuts(); options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); options.updateVisibleOverlayVisibility(); + options.refreshCurrentSubtitle?.(); options.syncOverlayShortcuts(); options.setWindowTracker(null); options.setAnkiIntegration(null); @@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => { 'register-shortcuts', 'update-visible-bounds', 'update-visible', + 'refresh-subtitle', 'sync-shortcuts', 'set-tracker', 'set-anki', diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index ce51c3f1..4ba31141 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -14,6 +14,7 @@ type OverlayRuntimeOptions = { updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; isVisibleOverlayVisible: () => boolean; updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; @@ -35,6 +36,8 @@ type OverlayRuntimeOptions = { ) => Promise; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }; export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { @@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; isVisibleOverlayVisible: () => boolean; updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; @@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { ) => Promise; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }) { return (): OverlayRuntimeOptions => ({ backendOverride: deps.getBackendOverride(), @@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds, isVisibleOverlayVisible: deps.isVisibleOverlayVisible, updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, + refreshCurrentSubtitle: deps.refreshCurrentSubtitle, getOverlayWindows: deps.getOverlayWindows, syncOverlayShortcuts: deps.syncOverlayShortcuts, setWindowTracker: deps.setWindowTracker, @@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration, + bindOverlayOwner: deps.bindOverlayOwner, + releaseOverlayOwner: deps.releaseOverlayOwner, }); } diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index e281691c..eee7c1fd 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb getVisibleOverlayVisible: () => true, getForceMousePassthrough: () => true, getWindowTracker: () => tracker, + getLastKnownWindowsForegroundProcessName: () => 'mpv', + getWindowsOverlayProcessName: () => 'subminer', + getWindowsFocusHandoffGraceActive: () => true, getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown) => { trackerNotReadyWarningShown = shown; @@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb }, updateVisibleOverlayBounds: () => calls.push('visible-bounds'), ensureOverlayWindowLevel: () => calls.push('ensure-level'), + syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'), syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`), enforceOverlayLayerOrder: () => calls.push('enforce-order'), syncOverlayShortcuts: () => calls.push('sync-shortcuts'), @@ -36,10 +40,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb assert.equal(deps.getModalActive(), true); assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getForceMousePassthrough(), true); + assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); + assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); + assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true); assert.equal(deps.getTrackerNotReadyWarningShown(), false); deps.setTrackerNotReadyWarningShown(true); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); deps.ensureOverlayWindowLevel(mainWindow); + deps.syncWindowsOverlayToMpvZOrder?.(mainWindow); deps.syncPrimaryOverlayWindowLayer('visible'); deps.enforceOverlayLayerOrder(); deps.syncOverlayShortcuts(); @@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb 'tracker-warning:true', 'visible-bounds', 'ensure-level', + 'sync-windows-z-order', 'primary-layer:visible', 'enforce-order', 'sync-shortcuts', diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index 2d3063db..f4b7761f 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getForceMousePassthrough: () => deps.getForceMousePassthrough(), getWindowTracker: () => deps.getWindowTracker(), + getLastKnownWindowsForegroundProcessName: () => + deps.getLastKnownWindowsForegroundProcessName?.() ?? null, + getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null, + getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false, getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) => + deps.syncWindowsOverlayToMpvZOrder?.(window), syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index 881289de..45633461 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; yomitanSession?: Session | null; }, @@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; getYomitanSession?: () => Session | null; }) { @@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { isOverlayVisible: deps.isOverlayVisible, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, forwardTabToMpv: deps.forwardTabToMpv, + onVisibleWindowBlurred: deps.onVisibleWindowBlurred, + onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, getYomitanSession: () => deps.getYomitanSession?.() ?? null, }); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index 9219ad11..d1c71491 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler(deps: { isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; yomitanSession?: Session | null; }, @@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler(deps: { isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; getYomitanSession?: () => Session | null; }) { @@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler(deps: { isOverlayVisible: deps.isOverlayVisible, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, forwardTabToMpv: deps.forwardTabToMpv, + onVisibleWindowBlurred: deps.onVisibleWindowBlurred, + onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, yomitanSession: deps.getYomitanSession?.() ?? null, }); diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index a2370c47..ee69267b 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -1041,6 +1041,168 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a } }); +test('visibility recovery re-enables subtitle hover without needing a fresh pointer move', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + let visibilityState: 'hidden' | 'visible' = 'visible'; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + get visibilityState() { + return visibilityState; + }, + elementFromPoint: () => ctx.dom.subtitleContainer, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + + ctx.state.isOverSubtitle = false; + ctx.dom.overlay.classList.remove('interactive'); + ignoreCalls.length = 0; + visibilityState = 'hidden'; + visibilityState = 'visible'; + + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener({}); + } + + assert.equal(ctx.state.isOverSubtitle, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + let hoveredElement: unknown = null; + let visibilityState: 'hidden' | 'visible' = 'visible'; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + get visibilityState() { + return visibilityState; + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 320, clientY: 180 }); + } + + ctx.dom.overlay.classList.add('interactive'); + ignoreCalls.length = 0; + visibilityState = 'hidden'; + visibilityState = 'visible'; + + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener({}); + } + + assert.equal(ctx.state.isOverSubtitle, false); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => { const ctx = createMouseTestContext(); const originalWindow = globalThis.window; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index b0203005..2c45d9a0 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -166,7 +166,7 @@ export function createMouseHandlers( } } - function restorePointerInteractionState(): void { + function resyncPointerInteractionState(options: { allowInteractiveFallback: boolean }): void { const pointerPosition = lastPointerPosition; pendingPointerResync = false; if (pointerPosition) { @@ -177,7 +177,11 @@ export function createMouseHandlers( } syncOverlayMouseIgnoreState(ctx); - if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) { + if ( + !options.allowInteractiveFallback || + !ctx.platform.shouldToggleMouseIgnore || + ctx.state.isOverSubtitle + ) { return; } @@ -186,6 +190,10 @@ export function createMouseHandlers( window.electronAPI.setIgnoreMouseEvents(false); } + function restorePointerInteractionState(): void { + resyncPointerInteractionState({ allowInteractiveFallback: true }); + } + function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void { if (!pendingPointerResync) { return; @@ -392,6 +400,12 @@ export function createMouseHandlers( syncHoverStateFromTrackedPointer(event); maybeResyncPointerHoverState(event); }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') { + return; + } + resyncPointerInteractionState({ allowInteractiveFallback: false }); + }); } function setupSelectionObserver(): void { @@ -432,7 +446,7 @@ export function createMouseHandlers( window.addEventListener('blur', () => { queueMicrotask(() => { - if (typeof document === 'undefined' || document.visibilityState !== 'visible') { + if (typeof document === 'undefined' || document.visibilityState !== 'visible') { return; } reconcilePopupInteraction({ reclaimFocus: true }); diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts index dfaf115a..2c6e1617 100644 --- a/src/renderer/overlay-mouse-ignore.test.ts +++ b/src/renderer/overlay-mouse-ignore.test.ts @@ -15,6 +15,53 @@ function createClassList() { }; } +test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const originalWindow = globalThis.window; + + Object.assign(globalThis, { + window: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + }, + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + yomitanPopupVisible: false, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: false, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null, + }, + } as never); + + assert.equal(classList.contains('interactive'), false); + assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]); + } finally { + Object.assign(globalThis, { window: originalWindow }); + } +}); + test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => { const classList = createClassList(); const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 9d420cfe..df4744a4 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -529,6 +529,9 @@ async function init(): Promise { if (ctx.platform.isMacOSPlatform) { document.body.classList.add('platform-macos'); } + if (ctx.platform.shouldToggleMouseIgnore) { + syncOverlayMouseIgnoreState(ctx); + } window.electronAPI.onSubtitle((data: SubtitleData) => { runGuarded('subtitle:update', () => { @@ -656,10 +659,6 @@ async function init(): Promise { ); measurementReporter.schedule(); - if (ctx.platform.shouldToggleMouseIgnore) { - syncOverlayMouseIgnoreState(ctx); - } - measurementReporter.emitNow(); } diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index d19172c3..87d3c93a 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -62,6 +62,10 @@ export abstract class BaseWindowTracker { return this.targetWindowFocused; } + isTargetWindowMinimized(): boolean { + return false; + } + protected updateTargetWindowFocused(focused: boolean): void { if (this.targetWindowFocused === focused) { return; diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts new file mode 100644 index 00000000..7fef3753 --- /dev/null +++ b/src/window-trackers/win32.ts @@ -0,0 +1,249 @@ +import koffi from 'koffi'; + +const user32 = koffi.load('user32.dll'); +const dwmapi = koffi.load('dwmapi.dll'); +const kernel32 = koffi.load('kernel32.dll'); + +const RECT = koffi.struct('RECT', { + Left: 'int', + Top: 'int', + Right: 'int', + Bottom: 'int', +}); + +const MARGINS = koffi.struct('MARGINS', { + cxLeftWidth: 'int', + cxRightWidth: 'int', + cyTopHeight: 'int', + cyBottomHeight: 'int', +}); + +const WNDENUMPROC = koffi.proto('bool __stdcall WNDENUMPROC(intptr hwnd, intptr lParam)'); + +const EnumWindows = user32.func('bool __stdcall EnumWindows(WNDENUMPROC *cb, intptr lParam)'); +const IsWindowVisible = user32.func('bool __stdcall IsWindowVisible(intptr hwnd)'); +const IsIconic = user32.func('bool __stdcall IsIconic(intptr hwnd)'); +const GetForegroundWindow = user32.func('intptr __stdcall GetForegroundWindow()'); +const SetWindowPos = user32.func( + 'bool __stdcall SetWindowPos(intptr hwnd, intptr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags)', +); +const GetWindowThreadProcessId = user32.func( + 'uint __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint *lpdwProcessId)', +); +const GetWindowLongW = user32.func('int __stdcall GetWindowLongW(intptr hwnd, int nIndex)'); +const SetWindowLongPtrW = user32.func( + 'intptr __stdcall SetWindowLongPtrW(intptr hwnd, int nIndex, intptr dwNewLong)', +); +const GetWindowFn = user32.func('intptr __stdcall GetWindow(intptr hwnd, uint uCmd)'); +const GetWindowRect = user32.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ RECT *lpRect)'); + +const DwmGetWindowAttribute = dwmapi.func( + 'int __stdcall DwmGetWindowAttribute(intptr hwnd, uint dwAttribute, _Out_ RECT *pvAttribute, uint cbAttribute)', +); +const DwmExtendFrameIntoClientArea = dwmapi.func( + 'int __stdcall DwmExtendFrameIntoClientArea(intptr hwnd, MARGINS *pMarInset)', +); + +const OpenProcess = kernel32.func( + 'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)', +); +const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)'); +const QueryFullProcessImageNameW = kernel32.func( + 'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)', +); + +const GWL_EXSTYLE = -20; +const WS_EX_TOPMOST = 0x00000008; +const GWLP_HWNDPARENT = -8; +const GW_HWNDPREV = 3; +const DWMWA_EXTENDED_FRAME_BOUNDS = 9; +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; +const SWP_NOSIZE = 0x0001; +const SWP_NOMOVE = 0x0002; +const SWP_NOACTIVATE = 0x0010; +const SWP_NOOWNERZORDER = 0x0200; +const SWP_FLAGS = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_NOOWNERZORDER; +const HWND_TOP = 0; +const HWND_BOTTOM = 1; +const HWND_TOPMOST = -1; +const HWND_NOTOPMOST = -2; + +function extendOverlayFrameIntoClientArea(overlayHwnd: number): void { + DwmExtendFrameIntoClientArea(overlayHwnd, { + cxLeftWidth: -1, + cxRightWidth: -1, + cyTopHeight: -1, + cyBottomHeight: -1, + }); +} + +export interface WindowBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface MpvWindowMatch { + hwnd: number; + bounds: WindowBounds; + area: number; + isForeground: boolean; +} + +export interface MpvPollResult { + matches: MpvWindowMatch[]; + focusState: boolean; + windowState: 'visible' | 'minimized' | 'not-found'; +} + +function getWindowBounds(hwnd: number): WindowBounds | null { + const rect = { Left: 0, Top: 0, Right: 0, Bottom: 0 }; + const hr = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, koffi.sizeof(RECT)); + if (hr !== 0) { + if (!GetWindowRect(hwnd, rect)) { + return null; + } + } + + const width = rect.Right - rect.Left; + const height = rect.Bottom - rect.Top; + if (width <= 0 || height <= 0) return null; + + return { x: rect.Left, y: rect.Top, width, height }; +} + +function getProcessNameByPid(pid: number): string | null { + const hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (!hProcess) return null; + + try { + const buffer = new Uint16Array(260); + const size = new Uint32Array([260]); + + if (!QueryFullProcessImageNameW(hProcess, 0, buffer, size)) { + return null; + } + + const fullPath = String.fromCharCode(...buffer.slice(0, size[0])); + const fileName = fullPath.split('\\').pop() || ''; + return fileName.replace(/\.exe$/i, ''); + } finally { + CloseHandle(hProcess); + } +} + +export function findMpvWindows(): MpvPollResult { + const foregroundHwnd = GetForegroundWindow(); + const matches: MpvWindowMatch[] = []; + let hasMinimized = false; + let hasFocused = false; + const processNameCache = new Map(); + + const cb = koffi.register((hwnd: number, _lParam: number) => { + if (!IsWindowVisible(hwnd)) return true; + + const pid = new Uint32Array(1); + GetWindowThreadProcessId(hwnd, pid); + const pidValue = pid[0]!; + if (pidValue === 0) return true; + + let processName = processNameCache.get(pidValue); + if (processName === undefined) { + processName = getProcessNameByPid(pidValue); + processNameCache.set(pidValue, processName); + } + + if (!processName || processName.toLowerCase() !== 'mpv') return true; + + if (IsIconic(hwnd)) { + hasMinimized = true; + return true; + } + + const bounds = getWindowBounds(hwnd); + if (!bounds) return true; + + const isForeground = foregroundHwnd !== 0 && hwnd === foregroundHwnd; + if (isForeground) hasFocused = true; + + matches.push({ + hwnd, + bounds, + area: bounds.width * bounds.height, + isForeground, + }); + + return true; + }, koffi.pointer(WNDENUMPROC)); + + try { + EnumWindows(cb, 0); + } finally { + koffi.unregister(cb); + } + + return { + matches, + focusState: hasFocused, + windowState: matches.length > 0 ? 'visible' : hasMinimized ? 'minimized' : 'not-found', + }; +} + +export function getForegroundProcessName(): string | null { + const foregroundHwnd = GetForegroundWindow(); + if (!foregroundHwnd) return null; + + const pid = new Uint32Array(1); + GetWindowThreadProcessId(foregroundHwnd, pid); + const pidValue = pid[0]!; + if (pidValue === 0) return null; + + return getProcessNameByPid(pidValue); +} + +export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void { + SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); + extendOverlayFrameIntoClientArea(overlayHwnd); +} + +export function ensureOverlayTransparency(overlayHwnd: number): void { + extendOverlayFrameIntoClientArea(overlayHwnd); +} + +export function clearOverlayOwner(overlayHwnd: number): void { + SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0); +} + +export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void { + const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE); + const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0; + + const overlayExStyle = GetWindowLongW(overlayHwnd, GWL_EXSTYLE); + const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0; + + if (mpvIsTopmost && !overlayIsTopmost) { + SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS); + } else if (!mpvIsTopmost && overlayIsTopmost) { + SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); + } + + const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV); + if (windowAboveMpv !== 0 && windowAboveMpv === overlayHwnd) return; + + let insertAfter = HWND_TOP; + if (windowAboveMpv !== 0) { + const aboveExStyle = GetWindowLongW(windowAboveMpv, GWL_EXSTYLE); + const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0; + if (aboveIsTopmost === mpvIsTopmost) { + insertAfter = windowAboveMpv; + } + } + + SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS); +} + +export function lowerOverlay(overlayHwnd: number): void { + SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); + SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS); +} diff --git a/src/window-trackers/windows-helper.test.ts b/src/window-trackers/windows-helper.test.ts index 76712c2a..55686b1a 100644 --- a/src/window-trackers/windows-helper.test.ts +++ b/src/window-trackers/windows-helper.test.ts @@ -1,9 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + lowerWindowsOverlayInZOrder, + parseWindowTrackerHelperForegroundProcess, parseWindowTrackerHelperFocusState, parseWindowTrackerHelperOutput, + parseWindowTrackerHelperState, + queryWindowsForegroundProcessName, resolveWindowsTrackerHelper, + syncWindowsOverlayToMpvZOrder, } from './windows-helper'; test('parseWindowTrackerHelperOutput parses helper geometry output', () => { @@ -28,6 +33,105 @@ test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => { assert.equal(parseWindowTrackerHelperFocusState(''), null); }); +test('parseWindowTrackerHelperState parses helper stderr metadata', () => { + assert.equal(parseWindowTrackerHelperState('state=visible'), 'visible'); + assert.equal(parseWindowTrackerHelperState('focus=not-focused\nstate=minimized'), 'minimized'); + assert.equal(parseWindowTrackerHelperState('state=unknown'), null); + assert.equal(parseWindowTrackerHelperState(''), null); +}); + +test('parseWindowTrackerHelperForegroundProcess parses helper stdout metadata', () => { + assert.equal(parseWindowTrackerHelperForegroundProcess('process=mpv'), 'mpv'); + assert.equal(parseWindowTrackerHelperForegroundProcess('process=chrome'), 'chrome'); + assert.equal(parseWindowTrackerHelperForegroundProcess('not-found'), null); + assert.equal(parseWindowTrackerHelperForegroundProcess(''), null); +}); + +test('queryWindowsForegroundProcessName reads foreground process from powershell helper', async () => { + const processName = await queryWindowsForegroundProcessName({ + resolveHelper: () => ({ + kind: 'powershell', + command: 'powershell.exe', + args: ['-File', 'helper.ps1'], + helperPath: 'helper.ps1', + }), + runHelper: async () => ({ + stdout: 'process=mpv', + stderr: '', + }), + }); + + assert.equal(processName, 'mpv'); +}); + +test('queryWindowsForegroundProcessName returns null when no powershell helper is available', async () => { + const processName = await queryWindowsForegroundProcessName({ + resolveHelper: () => ({ + kind: 'native', + command: 'helper.exe', + args: [], + helperPath: 'helper.exe', + }), + }); + + assert.equal(processName, null); +}); + +test('syncWindowsOverlayToMpvZOrder forwards socket path and overlay handle to powershell helper', async () => { + let capturedMode: string | null = null; + let capturedArgs: string[] | null = null; + + const synced = await syncWindowsOverlayToMpvZOrder({ + overlayWindowHandle: '12345', + targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket', + resolveHelper: () => ({ + kind: 'powershell', + command: 'powershell.exe', + args: ['-File', 'helper.ps1'], + helperPath: 'helper.ps1', + }), + runHelper: async (_spec, mode, extraArgs = []) => { + capturedMode = mode; + capturedArgs = extraArgs; + return { + stdout: 'ok', + stderr: '', + }; + }, + }); + + assert.equal(synced, true); + assert.equal(capturedMode, 'bind-overlay'); + assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket', '12345']); +}); + +test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper', async () => { + let capturedMode: string | null = null; + let capturedArgs: string[] | null = null; + + const lowered = await lowerWindowsOverlayInZOrder({ + overlayWindowHandle: '67890', + resolveHelper: () => ({ + kind: 'powershell', + command: 'powershell.exe', + args: ['-File', 'helper.ps1'], + helperPath: 'helper.ps1', + }), + runHelper: async (_spec, mode, extraArgs = []) => { + capturedMode = mode; + capturedArgs = extraArgs; + return { + stdout: 'ok', + stderr: '', + }; + }, + }); + + assert.equal(lowered, true); + assert.equal(capturedMode, 'lower-overlay'); + assert.deepEqual(capturedArgs, ['67890']); +}); + test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => { const helper = resolveWindowsTrackerHelper({ dirname: 'C:\\repo\\dist\\window-trackers', diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts index cf91e27a..51291a72 100644 --- a/src/window-trackers/windows-helper.ts +++ b/src/window-trackers/windows-helper.ts @@ -19,6 +19,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { execFile, type ExecFileException } from 'child_process'; import type { WindowGeometry } from '../types'; import { createLogger } from '../logger'; @@ -26,6 +27,13 @@ const log = createLogger('tracker').child('windows-helper'); export type WindowsTrackerHelperKind = 'powershell' | 'native'; export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native'; +export type WindowsTrackerHelperRunMode = + | 'geometry' + | 'foreground-process' + | 'bind-overlay' + | 'lower-overlay' + | 'set-owner' + | 'clear-owner'; export type WindowsTrackerHelperLaunchSpec = { kind: WindowsTrackerHelperKind; @@ -219,6 +227,182 @@ export function parseWindowTrackerHelperFocusState(output: string): boolean | nu return null; } +export function parseWindowTrackerHelperState(output: string): 'visible' | 'minimized' | null { + const stateLine = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith('state=')); + + if (!stateLine) { + return null; + } + + const value = stateLine.slice('state='.length).trim().toLowerCase(); + if (value === 'visible') { + return 'visible'; + } + if (value === 'minimized') { + return 'minimized'; + } + + return null; +} + +export function parseWindowTrackerHelperForegroundProcess(output: string): string | null { + const processLine = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith('process=')); + + if (!processLine) { + return null; + } + + const value = processLine.slice('process='.length).trim(); + return value.length > 0 ? value : null; +} + +type WindowsTrackerHelperRunnerResult = { + stdout: string; + stderr: string; +}; + +function runWindowsTrackerHelperWithExecFile( + spec: WindowsTrackerHelperLaunchSpec, + mode: WindowsTrackerHelperRunMode, + extraArgs: string[] = [], +): Promise { + return new Promise((resolve, reject) => { + const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode]; + execFile( + spec.command, + [...spec.args, ...modeArgs, ...extraArgs], + { + encoding: 'utf-8', + timeout: 1000, + maxBuffer: 1024 * 1024, + windowsHide: true, + }, + (error: ExecFileException | null, stdout: string, stderr: string) => { + if (error) { + reject(Object.assign(error, { stderr })); + return; + } + resolve({ stdout, stderr }); + }, + ); + }); +} + +export async function queryWindowsForegroundProcessName(deps: { + resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null; + runHelper?: ( + spec: WindowsTrackerHelperLaunchSpec, + mode: WindowsTrackerHelperRunMode, + extraArgs?: string[], + ) => Promise; +} = {}): Promise { + const spec = + deps.resolveHelper?.() ?? + resolveWindowsTrackerHelper({ + helperModeEnv: 'powershell', + }); + if (!spec || spec.kind !== 'powershell') { + return null; + } + + const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile; + const { stdout } = await runHelper(spec, 'foreground-process'); + return parseWindowTrackerHelperForegroundProcess(stdout); +} + +export async function syncWindowsOverlayToMpvZOrder(deps: { + overlayWindowHandle: string; + targetMpvSocketPath?: string | null; + resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null; + runHelper?: ( + spec: WindowsTrackerHelperLaunchSpec, + mode: WindowsTrackerHelperRunMode, + extraArgs?: string[], + ) => Promise; +}): Promise { + const spec = + deps.resolveHelper?.() ?? + resolveWindowsTrackerHelper({ + helperModeEnv: 'powershell', + }); + if (!spec || spec.kind !== 'powershell') { + return false; + } + + const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile; + const extraArgs = [deps.targetMpvSocketPath ?? '', deps.overlayWindowHandle]; + const { stdout } = await runHelper(spec, 'bind-overlay', extraArgs); + return stdout.trim() === 'ok'; +} + +export async function lowerWindowsOverlayInZOrder(deps: { + overlayWindowHandle: string; + resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null; + runHelper?: ( + spec: WindowsTrackerHelperLaunchSpec, + mode: WindowsTrackerHelperRunMode, + extraArgs?: string[], + ) => Promise; +}): Promise { + const spec = + deps.resolveHelper?.() ?? + resolveWindowsTrackerHelper({ + helperModeEnv: 'powershell', + }); + if (!spec || spec.kind !== 'powershell') { + return false; + } + + const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile; + const { stdout } = await runHelper(spec, 'lower-overlay', [deps.overlayWindowHandle]); + return stdout.trim() === 'ok'; +} + +export function setWindowsOverlayOwnerNative(overlayHwnd: number, mpvHwnd: number): boolean { + try { + const win32 = require('./win32') as typeof import('./win32'); + win32.setOverlayOwner(overlayHwnd, mpvHwnd); + return true; + } catch { + return false; + } +} + +export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boolean { + try { + const win32 = require('./win32') as typeof import('./win32'); + win32.ensureOverlayTransparency(overlayHwnd); + return true; + } catch { + return false; + } +} + +export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean { + try { + const win32 = require('./win32') as typeof import('./win32'); + win32.clearOverlayOwner(overlayHwnd); + return true; + } catch { + return false; + } +} + +export function getWindowsForegroundProcessNameNative(): string | null { + try { + const win32 = require('./win32') as typeof import('./win32'); + return win32.getForegroundProcessName(); + } catch { + return null; + } +} + export function resolveWindowsTrackerHelper( options: ResolveWindowsTrackerHelperOptions = {}, ): WindowsTrackerHelperLaunchSpec | null { diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts index 2b643bd4..31d8bc7e 100644 --- a/src/window-trackers/windows-tracker.test.ts +++ b/src/window-trackers/windows-tracker.test.ts @@ -1,56 +1,62 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { WindowsWindowTracker } from './windows-tracker'; +import type { MpvPollResult } from './win32'; -test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => { - let helperCalls = 0; - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); +function mpvVisible( + overrides: Partial = {}, +): MpvPollResult { + return { + matches: [ + { + hwnd: 12345, + bounds: { + x: overrides.x ?? 0, + y: overrides.y ?? 0, + width: overrides.width ?? 1280, + height: overrides.height ?? 720, + }, + area: (overrides.width ?? 1280) * (overrides.height ?? 720), + isForeground: overrides.focused ?? true, + }, + ], + focusState: overrides.focused ?? true, + windowState: 'visible', + }; +} +const mpvNotFound: MpvPollResult = { + matches: [], + focusState: false, + windowState: 'not-found', +}; + +const mpvMinimized: MpvPollResult = { + matches: [], + focusState: false, + windowState: 'minimized', +}; + +test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => { + let pollCalls = 0; const tracker = new WindowsWindowTracker(undefined, { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async () => { - helperCalls += 1; - await gate; - return { - stdout: '0,0,640,360', - stderr: 'focus=focused', - }; + pollMpvWindows: () => { + pollCalls += 1; + return mpvVisible(); }, }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - assert.equal(helperCalls, 1); - - assert.ok(release); - release(); - await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(pollCalls, 2); }); -test('WindowsWindowTracker updates geometry from helper output', async () => { +test('WindowsWindowTracker updates geometry from poll output', () => { const tracker = new WindowsWindowTracker(undefined, { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async () => ({ - stdout: '10,20,1280,720', - stderr: 'focus=focused', - }), + pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(tracker.getGeometry(), { x: 10, @@ -61,59 +67,180 @@ test('WindowsWindowTracker updates geometry from helper output', async () => { assert.equal(tracker.isTargetWindowFocused(), true); }); -test('WindowsWindowTracker clears geometry for helper misses', async () => { +test('WindowsWindowTracker clears geometry for poll misses', () => { const tracker = new WindowsWindowTracker(undefined, { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async () => ({ - stdout: 'not-found', - stderr: 'focus=not-focused', - }), + pollMpvWindows: () => mpvNotFound, + trackingLossGraceMs: 0, }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.getGeometry(), null); assert.equal(tracker.isTargetWindowFocused(), false); }); -test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => { - const helperCalls: Array = []; - const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async (_spec, _mode, targetMpvSocketPath) => { - helperCalls.push(targetMpvSocketPath); - if (targetMpvSocketPath) { - return { - stdout: 'not-found', - stderr: 'focus=not-focused', - }; - } - return { - stdout: '25,30,1440,810', - stderr: 'focus=focused', - }; - }, +test('WindowsWindowTracker keeps the last geometry through a single poll miss', () => { + let callIndex = 0; + const outputs = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvNotFound, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + trackingLossGraceMs: 0, }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); - assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]); - assert.deepEqual(tracker.getGeometry(), { - x: 25, - y: 30, - width: 1440, - height: 810, - }); - assert.equal(tracker.isTargetWindowFocused(), true); + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); +}); + +test('WindowsWindowTracker drops tracking after grace window expires', () => { + let callIndex = 0; + let now = 1_000; + const outputs = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvNotFound, + mpvNotFound, + mpvNotFound, + mpvNotFound, + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), false); + assert.equal(tracker.getGeometry(), null); +}); + +test('WindowsWindowTracker keeps tracking through repeated poll misses inside grace window', () => { + let callIndex = 0; + let now = 1_000; + const outputs = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvNotFound, + mpvNotFound, + mpvNotFound, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 1_500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); +}); + +test('WindowsWindowTracker keeps tracking through a transient minimized report inside minimized grace window', () => { + let callIndex = 0; + let now = 1_000; + const outputs: MpvPollResult[] = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvMinimized, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + minimizedTrackingLossGraceMs: 200, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 100; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 100; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); +}); + +test('WindowsWindowTracker keeps tracking through repeated transient minimized reports inside minimized grace window', () => { + let callIndex = 0; + let now = 1_000; + const outputs: MpvPollResult[] = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvMinimized, + mpvMinimized, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + minimizedTrackingLossGraceMs: 500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowMinimized(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowMinimized(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowMinimized(), false); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); }); diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index 3c07cca2..fe03b082 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -16,80 +16,50 @@ along with this program. If not, see . */ -import { execFile, type ExecFileException } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; -import { - parseWindowTrackerHelperFocusState, - parseWindowTrackerHelperOutput, - resolveWindowsTrackerHelper, - type WindowsTrackerHelperLaunchSpec, -} from './windows-helper'; +import type { WindowGeometry } from '../types'; +import type { MpvPollResult } from './win32'; import { createLogger } from '../logger'; const log = createLogger('tracker').child('windows'); -type WindowsTrackerRunnerResult = { - stdout: string; - stderr: string; -}; - type WindowsTrackerDeps = { - resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null; - runHelper?: ( - spec: WindowsTrackerHelperLaunchSpec, - mode: 'geometry', - targetMpvSocketPath: string | null, - ) => Promise; + pollMpvWindows?: () => MpvPollResult; + maxConsecutiveMisses?: number; + trackingLossGraceMs?: number; + minimizedTrackingLossGraceMs?: number; + now?: () => number; }; -function runHelperWithExecFile( - spec: WindowsTrackerHelperLaunchSpec, - mode: 'geometry', - targetMpvSocketPath: string | null, -): Promise { - return new Promise((resolve, reject) => { - const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode]; - const args = targetMpvSocketPath - ? [...spec.args, ...modeArgs, targetMpvSocketPath] - : [...spec.args, ...modeArgs]; - execFile( - spec.command, - args, - { - encoding: 'utf-8', - timeout: 1000, - maxBuffer: 1024 * 1024, - windowsHide: true, - }, - (error: ExecFileException | null, stdout: string, stderr: string) => { - if (error) { - reject(Object.assign(error, { stderr })); - return; - } - resolve({ stdout, stderr }); - }, - ); - }); +function defaultPollMpvWindows(): MpvPollResult { + const win32 = require('./win32') as typeof import('./win32'); + return win32.findMpvWindows(); } export class WindowsWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private pollInFlight = false; - private helperSpec: WindowsTrackerHelperLaunchSpec | null; - private readonly targetMpvSocketPath: string | null; - private readonly runHelper: ( - spec: WindowsTrackerHelperLaunchSpec, - mode: 'geometry', - targetMpvSocketPath: string | null, - ) => Promise; - private lastExecErrorFingerprint: string | null = null; - private lastExecErrorLoggedAtMs = 0; + private readonly pollMpvWindows: () => MpvPollResult; + private readonly maxConsecutiveMisses: number; + private readonly trackingLossGraceMs: number; + private readonly minimizedTrackingLossGraceMs: number; + private readonly now: () => number; + private lastPollErrorFingerprint: string | null = null; + private lastPollErrorLoggedAtMs = 0; + private consecutiveMisses = 0; + private trackingLossStartedAtMs: number | null = null; + private targetWindowMinimized = false; - constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) { + constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) { super(); - this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; - this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper(); - this.runHelper = deps.runHelper ?? runHelperWithExecFile; + this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows; + this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2)); + this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500)); + this.minimizedTrackingLossGraceMs = Math.max( + 0, + Math.floor(deps.minimizedTrackingLossGraceMs ?? 500), + ); + this.now = deps.now ?? (() => Date.now()); } start(): void { @@ -104,72 +74,99 @@ export class WindowsWindowTracker extends BaseWindowTracker { } } - private maybeLogExecError(error: Error, stderr: string): void { - const now = Date.now(); - const fingerprint = `${error.message}|${stderr.trim()}`; - const shouldLog = - this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000; - if (!shouldLog) { - return; - } - - this.lastExecErrorFingerprint = fingerprint; - this.lastExecErrorLoggedAtMs = now; - log.warn('Windows helper execution failed', { - helperPath: this.helperSpec?.helperPath ?? null, - helperKind: this.helperSpec?.kind ?? null, - error: error.message, - stderr: stderr.trim(), - }); + override isTargetWindowMinimized(): boolean { + return this.targetWindowMinimized; } - private async runHelperWithSocketFallback(): Promise { - if (!this.helperSpec) { - return { stdout: 'not-found', stderr: '' }; - } + private maybeLogPollError(error: Error): void { + const now = Date.now(); + const fingerprint = error.message; + const shouldLog = + this.lastPollErrorFingerprint !== fingerprint || now - this.lastPollErrorLoggedAtMs >= 5000; + if (!shouldLog) return; - try { - const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath); - const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout); - if (primaryGeometry || !this.targetMpvSocketPath) { - return primary; - } - } catch (error) { - if (!this.targetMpvSocketPath) { - throw error; - } - } + this.lastPollErrorFingerprint = fingerprint; + this.lastPollErrorLoggedAtMs = now; + log.warn('Windows native poll failed', { error: error.message }); + } - return await this.runHelper(this.helperSpec, 'geometry', null); + private resetTrackingLossState(): void { + this.consecutiveMisses = 0; + this.trackingLossStartedAtMs = null; + } + + private shouldDropTracking(graceMs = this.trackingLossGraceMs): boolean { + if (!this.isTracking()) { + return true; + } + if (graceMs === 0) { + return this.consecutiveMisses >= this.maxConsecutiveMisses; + } + if (this.trackingLossStartedAtMs === null) { + this.trackingLossStartedAtMs = this.now(); + return false; + } + return this.now() - this.trackingLossStartedAtMs > graceMs; + } + + private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void { + this.consecutiveMisses += 1; + if (this.shouldDropTracking(graceMs)) { + this.updateGeometry(null); + this.resetTrackingLossState(); + } + } + + private selectBestMatch( + result: MpvPollResult, + ): { geometry: WindowGeometry; focused: boolean } | null { + if (result.matches.length === 0) return null; + + const focusedMatch = result.matches.find((m) => m.isForeground); + const best = + focusedMatch ?? + result.matches.sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!; + + return { + geometry: best.bounds, + focused: best.isForeground, + }; } private pollGeometry(): void { - if (this.pollInFlight || !this.helperSpec) { - return; - } - + if (this.pollInFlight) return; this.pollInFlight = true; - void this.runHelperWithSocketFallback() - .then(({ stdout, stderr }) => { - const geometry = parseWindowTrackerHelperOutput(stdout); - const focusState = parseWindowTrackerHelperFocusState(stderr); - this.updateTargetWindowFocused(focusState ?? Boolean(geometry)); - this.updateGeometry(geometry); - }) - .catch((error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)); - const stderr = - typeof error === 'object' && - error !== null && - 'stderr' in error && - typeof (error as { stderr?: unknown }).stderr === 'string' - ? (error as { stderr: string }).stderr - : ''; - this.maybeLogExecError(err, stderr); - this.updateGeometry(null); - }) - .finally(() => { - this.pollInFlight = false; - }); + + try { + const result = this.pollMpvWindows(); + const best = this.selectBestMatch(result); + + if (best) { + this.resetTrackingLossState(); + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(best.focused); + this.updateGeometry(best.geometry); + return; + } + + if (result.windowState === 'minimized') { + this.targetWindowMinimized = true; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(this.minimizedTrackingLossGraceMs); + return; + } + + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(); + } catch (error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)); + this.maybeLogPollError(err); + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(); + } finally { + this.pollInFlight = false; + } } }