From 3e7573c9fc2da5fce2350e7974bc926dc225ff1c Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 10 Apr 2026 01:55:09 -0700 Subject: [PATCH] Fix Windows overlay z-order on minimize/restore and improve hover stability Use native synchronous z-order binding (koffi) instead of async PowerShell for overlay positioning, eliminating the 200-500ms delay that left the overlay behind mpv after restore. Hide the overlay immediately when mpv is minimized so the full show/reveal/z-order flow triggers cleanly on restore. Also adds hover suppression after visibility recovery and window resize to prevent spurious auto-pause, Windows secondary subtitle titlebar fix, and z-order sync burst retries on geometry changes. --- changes/fix-windows-overlay-z-order.md | 3 +- scripts/get-mpv-window-windows.ps1 | 1 + src/core/services/overlay-visibility.test.ts | 4 + src/core/services/overlay-visibility.ts | 25 ++- src/main.ts | 50 ++++- .../overlay-window-layout-main-deps.ts | 1 + .../runtime/overlay-window-layout.test.ts | 16 ++ src/main/runtime/overlay-window-layout.ts | 2 + src/renderer/handlers/mouse.test.ts | 193 ++++++++++++++++++ src/renderer/handlers/mouse.ts | 32 ++- src/window-trackers/win32.ts | 1 + src/window-trackers/windows-helper.ts | 15 ++ 12 files changed, 333 insertions(+), 10 deletions(-) diff --git a/changes/fix-windows-overlay-z-order.md b/changes/fix-windows-overlay-z-order.md index 99a13e66..d5b1f57e 100644 --- a/changes/fix-windows-overlay-z-order.md +++ b/changes/fix-windows-overlay-z-order.md @@ -3,8 +3,9 @@ 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 Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click. - 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. +- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area. diff --git a/scripts/get-mpv-window-windows.ps1 b/scripts/get-mpv-window-windows.ps1 index 6eaa27f9..9d7faf5b 100644 --- a/scripts/get-mpv-window-windows.ps1 +++ b/scripts/get-mpv-window-windows.ps1 @@ -302,6 +302,7 @@ public static class SubMinerWindowsHelper { [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) $targetWindow = [IntPtr]$bestMatch.HWnd + [void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow) $targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE) $targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0 diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index eeefbb52..e26089b5 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -242,6 +242,7 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked test('Windows visible overlay restores opacity after the deferred reveal delay', async () => { const { window, calls, getOpacity } = createMainWindowRecorder(); + let syncWindowsZOrderCalls = 0; const tracker: WindowTrackerStub = { isTracking: () => true, getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), @@ -260,6 +261,7 @@ test('Windows visible overlay restores opacity after the deferred reveal delay', calls.push('ensure-level'); }, syncWindowsOverlayToMpvZOrder: () => { + syncWindowsZOrderCalls += 1; calls.push('sync-windows-z-order'); }, syncPrimaryOverlayWindowLayer: () => { @@ -276,8 +278,10 @@ test('Windows visible overlay restores opacity after the deferred reveal delay', } as never); assert.equal(getOpacity(), 0); + assert.equal(syncWindowsZOrderCalls, 1); await new Promise((resolve) => setTimeout(resolve, 60)); assert.equal(getOpacity(), 1); + assert.equal(syncWindowsZOrderCalls, 2); assert.ok(calls.includes('opacity:1')); }); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 08564f69..7126d6d8 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -25,7 +25,10 @@ function clearPendingWindowsOverlayReveal(window: BrowserWindow): void { pendingWindowsOverlayRevealTimeoutByWindow.delete(window); } -function scheduleWindowsOverlayReveal(window: BrowserWindow): void { +function scheduleWindowsOverlayReveal( + window: BrowserWindow, + onReveal?: (window: BrowserWindow) => void, +): void { clearPendingWindowsOverlayReveal(window); const timeout = setTimeout(() => { pendingWindowsOverlayRevealTimeoutByWindow.delete(window); @@ -33,6 +36,7 @@ function scheduleWindowsOverlayReveal(window: BrowserWindow): void { return; } setOverlayWindowOpacity(window, 1); + onReveal?.(window); }, WINDOWS_OVERLAY_REVEAL_DELAY_MS); pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout); } @@ -154,14 +158,18 @@ export function updateVisibleOverlayVisibility(args: { setOverlayWindowOpacity(mainWindow, 0); mainWindow.showInactive(); mainWindow.setIgnoreMouseEvents(true, { forward: true }); - scheduleWindowsOverlayReveal(mainWindow); + scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay + ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) + : undefined); } else { if (args.isWindowsPlatform) { setOverlayWindowOpacity(mainWindow, 0); } mainWindow.show(); if (args.isWindowsPlatform) { - scheduleWindowsOverlayReveal(mainWindow); + scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay + ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) + : undefined); } } } @@ -199,6 +207,17 @@ export function updateVisibleOverlayVisibility(args: { } if (args.windowTracker && args.windowTracker.isTracking()) { + if ( + args.isWindowsPlatform && + typeof args.windowTracker.isTargetWindowMinimized === 'function' && + args.windowTracker.isTargetWindowMinimized() + ) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + mainWindow.hide(); + args.syncOverlayShortcuts(); + return; + } args.setTrackerNotReadyWarningShown(false); const geometry = args.windowTracker.getGeometry(); if (geometry) { diff --git a/src/main.ts b/src/main.ts index 2583c8cb..eb96c851 100644 --- a/src/main.ts +++ b/src/main.ts @@ -131,6 +131,7 @@ import { } from './logger'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { + bindWindowsOverlayAboveMpvNative, clearWindowsOverlayOwnerNative, ensureWindowsOverlayTransparencyNative, getWindowsForegroundProcessNameNative, @@ -1886,9 +1887,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( ); const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; +const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; +let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; @@ -1903,6 +1906,13 @@ function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { windowsVisibleOverlayBlurRefreshTimeouts = []; } +function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { + for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) { + clearTimeout(timeout); + } + windowsVisibleOverlayZOrderRetryTimeouts = []; +} + function getWindowsNativeWindowHandle(window: BrowserWindow): string { const handle = window.getNativeWindowHandle(); return handle.length >= 8 @@ -1948,10 +1958,20 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { return false; } - return await syncWindowsOverlayToMpvZOrder({ + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) { + (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); + return true; + } + + const synced = await syncWindowsOverlayToMpvZOrder({ overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow), targetMpvSocketPath: appState.mpvSocketPath, }); + if (synced) { + (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); + } + return synced; } function requestWindowsVisibleOverlayZOrderSync(): void { @@ -1980,6 +2000,23 @@ function requestWindowsVisibleOverlayZOrderSync(): void { }); } +function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void { + if (process.platform !== 'win32') { + return; + } + + clearWindowsVisibleOverlayZOrderRetryTimeouts(); + for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) { + const retryTimeout = setTimeout(() => { + windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter( + (timeout) => timeout !== retryTimeout, + ); + requestWindowsVisibleOverlayZOrderSync(); + }, delayMs); + windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout); + } +} + function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { return ( process.platform === 'win32' && @@ -3869,6 +3906,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void { const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), + afterSetOverlayWindowBounds: () => { + if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + return; + } + scheduleWindowsVisibleOverlayZOrderSyncBurst(); + }, }); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( @@ -4929,6 +4972,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = bindOverlayOwner: () => { const mainWindow = overlayManager.getMainWindow(); if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) { + return; + } const tracker = appState.windowTracker; const mpvResult = tracker ? (() => { @@ -4943,7 +4990,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = })() : null; if (!mpvResult) return; - const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) { logger.warn('Failed to set overlay owner via koffi'); } diff --git a/src/main/runtime/overlay-window-layout-main-deps.ts b/src/main/runtime/overlay-window-layout-main-deps.ts index d5440214..f8e70ebc 100644 --- a/src/main/runtime/overlay-window-layout-main-deps.ts +++ b/src/main/runtime/overlay-window-layout-main-deps.ts @@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler( ) { return (): UpdateVisibleOverlayBoundsMainDeps => ({ setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry), + afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry), }); } diff --git a/src/main/runtime/overlay-window-layout.test.ts b/src/main/runtime/overlay-window-layout.test.ts index b1c281fc..84a7be43 100644 --- a/src/main/runtime/overlay-window-layout.test.ts +++ b/src/main/runtime/overlay-window-layout.test.ts @@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => { assert.deepEqual(calls, [geometry]); }); +test('visible bounds handler runs follow-up callback after applying geometry', () => { + const calls: string[] = []; + const geometry = { x: 0, y: 0, width: 100, height: 50 }; + const handleVisible = createUpdateVisibleOverlayBoundsHandler({ + setOverlayWindowBounds: () => calls.push('set-bounds'), + afterSetOverlayWindowBounds: (nextGeometry) => { + assert.deepEqual(nextGeometry, geometry); + calls.push('after-bounds'); + }, + }); + + handleVisible(geometry); + + assert.deepEqual(calls, ['set-bounds', 'after-bounds']); +}); + test('ensure overlay window level handler delegates to core', () => { const calls: string[] = []; const ensureLevel = createEnsureOverlayWindowLevelHandler({ diff --git a/src/main/runtime/overlay-window-layout.ts b/src/main/runtime/overlay-window-layout.ts index f14da487..d1d0330a 100644 --- a/src/main/runtime/overlay-window-layout.ts +++ b/src/main/runtime/overlay-window-layout.ts @@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types'; export function createUpdateVisibleOverlayBoundsHandler(deps: { setOverlayWindowBounds: (geometry: WindowGeometry) => void; + afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void; }) { return (geometry: WindowGeometry): void => { deps.setOverlayWindowBounds(geometry); + deps.afterSetOverlayWindowBounds?.(geometry); }; } diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index ee69267b..03f89aea 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -1122,6 +1122,199 @@ test('visibility recovery re-enables subtitle hover without needing a fresh poin } }); +test('visibility recovery ignores synthetic subtitle enter until the pointer moves again', async () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const mpvCommands: Array<(string | number)[]> = []; + const documentListeners = new Map void>>(); + let hoveredElement: unknown = ctx.dom.subtitleContainer; + let visibilityState: 'hidden' | 'visible' = 'visible'; + let subtitleHoverAutoPauseEnabled = false; + 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: () => subtitleHoverAutoPauseEnabled, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + ignoreCalls.length = 0; + visibilityState = 'hidden'; + visibilityState = 'visible'; + subtitleHoverAutoPauseEnabled = true; + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener({}); + } + + await handlers.handlePrimaryMouseEnter(); + assert.deepEqual(mpvCommands, []); + + hoveredElement = null; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 32, clientY: 48 }); + } + + hoveredElement = ctx.dom.subtitleContainer; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('window resize ignores synthetic subtitle enter until the pointer moves again', async () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const mpvCommands: Array<(string | number)[]> = []; + const windowListeners = new Map void>>(); + const documentListeners = new Map void>>(); + let hoveredElement: unknown = ctx.dom.subtitleContainer; + let subtitleHoverAutoPauseEnabled = false; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + innerHeight: 1000, + }, + }); + 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); + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupPointerTracking(); + handlers.setupResizeHandler(); + + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + subtitleHoverAutoPauseEnabled = true; + for (const listener of windowListeners.get('resize') ?? []) { + listener(); + } + + await handlers.handlePrimaryMouseEnter(); + assert.deepEqual(mpvCommands, []); + + hoveredElement = null; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 32, clientY: 48 }); + } + + hoveredElement = ctx.dom.subtitleContainer; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + } 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; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 2c45d9a0..5180eb73 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -9,6 +9,9 @@ import { isYomitanPopupIframe, } from '../yomitan-popup.js'; +const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery'; +const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize'; + export function createMouseHandlers( ctx: RendererContext, options: { @@ -35,6 +38,7 @@ export function createMouseHandlers( let pausedByYomitanPopup = false; let lastPointerPosition: { clientX: number; clientY: number } | null = null; let pendingPointerResync = false; + let suppressDirectHoverEnterSource: string | null = null; function getPopupVisibilityFromDom(): boolean { return typeof document !== 'undefined' && isYomitanPopupVisible(document); @@ -142,6 +146,7 @@ export function createMouseHandlers( return; } + suppressDirectHoverEnterSource = null; const wasOverSubtitle = ctx.state.isOverSubtitle; const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains( 'secondary-sub-hover-active', @@ -149,7 +154,7 @@ export function createMouseHandlers( const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY); if (!wasOverSubtitle && hoverState.isOverSubtitle) { - void handleMouseEnter(undefined, hoverState.overSecondarySubtitle); + void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer'); return; } @@ -166,9 +171,13 @@ export function createMouseHandlers( } } - function resyncPointerInteractionState(options: { allowInteractiveFallback: boolean }): void { + function resyncPointerInteractionState(options: { + allowInteractiveFallback: boolean; + suppressDirectHoverEnterSource?: string | null; + }): void { const pointerPosition = lastPointerPosition; pendingPointerResync = false; + suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null; if (pointerPosition) { syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY); } else { @@ -288,7 +297,15 @@ export function createMouseHandlers( syncOverlayMouseIgnoreState(ctx); } - async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise { + async function handleMouseEnter( + _event?: MouseEvent, + showSecondaryHover = false, + source: 'direct' | 'tracked-pointer' = 'direct', + ): Promise { + if (source === 'direct' && suppressDirectHoverEnterSource !== null) { + return; + } + ctx.state.isOverSubtitle = true; if (showSecondaryHover) { ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active'); @@ -386,6 +403,10 @@ export function createMouseHandlers( function setupResizeHandler(): void { window.addEventListener('resize', () => { options.applyYPercent(options.getCurrentYPercent()); + resyncPointerInteractionState({ + allowInteractiveFallback: false, + suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE, + }); }); } @@ -404,7 +425,10 @@ export function createMouseHandlers( if (document.visibilityState !== 'visible') { return; } - resyncPointerInteractionState({ allowInteractiveFallback: false }); + resyncPointerInteractionState({ + allowInteractiveFallback: false, + suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE, + }); }); } diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts index 7fef3753..750720a8 100644 --- a/src/window-trackers/win32.ts +++ b/src/window-trackers/win32.ts @@ -216,6 +216,7 @@ export function clearOverlayOwner(overlayHwnd: number): void { } export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void { + SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE); const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0; diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts index 51291a72..dbe16d15 100644 --- a/src/window-trackers/windows-helper.ts +++ b/src/window-trackers/windows-helper.ts @@ -384,6 +384,21 @@ export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boo } } +export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number): boolean { + try { + const win32 = require('./win32') as typeof import('./win32'); + const poll = win32.findMpvWindows(); + const focused = poll.matches.find((m) => m.isForeground); + const best = focused ?? poll.matches.sort((a, b) => b.area - a.area)[0]; + if (!best) return false; + win32.bindOverlayAboveMpv(overlayHwnd, best.hwnd); + win32.ensureOverlayTransparency(overlayHwnd); + return true; + } catch { + return false; + } +} + export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean { try { const win32 = require('./win32') as typeof import('./win32');