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.
This commit is contained in:
2026-04-10 01:55:09 -07:00
parent 20a0efe572
commit 3e7573c9fc
12 changed files with 333 additions and 10 deletions

View File

@@ -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 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 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 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 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 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 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.

View File

@@ -302,6 +302,7 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd $targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE) $targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0 $targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0

View File

@@ -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 () => { test('Windows visible overlay restores opacity after the deferred reveal delay', async () => {
const { window, calls, getOpacity } = createMainWindowRecorder(); const { window, calls, getOpacity } = createMainWindowRecorder();
let syncWindowsZOrderCalls = 0;
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), 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'); calls.push('ensure-level');
}, },
syncWindowsOverlayToMpvZOrder: () => { syncWindowsOverlayToMpvZOrder: () => {
syncWindowsZOrderCalls += 1;
calls.push('sync-windows-z-order'); calls.push('sync-windows-z-order');
}, },
syncPrimaryOverlayWindowLayer: () => { syncPrimaryOverlayWindowLayer: () => {
@@ -276,8 +278,10 @@ test('Windows visible overlay restores opacity after the deferred reveal delay',
} as never); } as never);
assert.equal(getOpacity(), 0); assert.equal(getOpacity(), 0);
assert.equal(syncWindowsZOrderCalls, 1);
await new Promise<void>((resolve) => setTimeout(resolve, 60)); await new Promise<void>((resolve) => setTimeout(resolve, 60));
assert.equal(getOpacity(), 1); assert.equal(getOpacity(), 1);
assert.equal(syncWindowsZOrderCalls, 2);
assert.ok(calls.includes('opacity:1')); assert.ok(calls.includes('opacity:1'));
}); });

View File

@@ -25,7 +25,10 @@ function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
pendingWindowsOverlayRevealTimeoutByWindow.delete(window); pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
} }
function scheduleWindowsOverlayReveal(window: BrowserWindow): void { function scheduleWindowsOverlayReveal(
window: BrowserWindow,
onReveal?: (window: BrowserWindow) => void,
): void {
clearPendingWindowsOverlayReveal(window); clearPendingWindowsOverlayReveal(window);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
pendingWindowsOverlayRevealTimeoutByWindow.delete(window); pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
@@ -33,6 +36,7 @@ function scheduleWindowsOverlayReveal(window: BrowserWindow): void {
return; return;
} }
setOverlayWindowOpacity(window, 1); setOverlayWindowOpacity(window, 1);
onReveal?.(window);
}, WINDOWS_OVERLAY_REVEAL_DELAY_MS); }, WINDOWS_OVERLAY_REVEAL_DELAY_MS);
pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout); pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout);
} }
@@ -154,14 +158,18 @@ export function updateVisibleOverlayVisibility(args: {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
mainWindow.showInactive(); mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true }); mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(mainWindow); scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
} else { } else {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
} }
mainWindow.show(); mainWindow.show();
if (args.isWindowsPlatform) { 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.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); args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {

View File

@@ -131,6 +131,7 @@ import {
} from './logger'; } from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import { import {
bindWindowsOverlayAboveMpvNative,
clearWindowsOverlayOwnerNative, clearWindowsOverlayOwnerNative,
ensureWindowsOverlayTransparencyNative, ensureWindowsOverlayTransparencyNative,
getWindowsForegroundProcessNameNative, 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_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_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null; let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
@@ -1903,6 +1906,13 @@ function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
windowsVisibleOverlayBlurRefreshTimeouts = []; windowsVisibleOverlayBlurRefreshTimeouts = [];
} }
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayZOrderRetryTimeouts = [];
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string { function getWindowsNativeWindowHandle(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle(); const handle = window.getNativeWindowHandle();
return handle.length >= 8 return handle.length >= 8
@@ -1948,10 +1958,20 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
return false; 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), overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow),
targetMpvSocketPath: appState.mpvSocketPath, targetMpvSocketPath: appState.mpvSocketPath,
}); });
if (synced) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
}
return synced;
} }
function requestWindowsVisibleOverlayZOrderSync(): void { 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 { function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
return ( return (
process.platform === 'win32' && process.platform === 'win32' &&
@@ -3869,6 +3906,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void {
const buildUpdateVisibleOverlayBoundsMainDepsHandler = const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return;
}
scheduleWindowsVisibleOverlayZOrderSyncBurst();
},
}); });
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
@@ -4929,6 +4972,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
bindOverlayOwner: () => { bindOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
return;
}
const tracker = appState.windowTracker; const tracker = appState.windowTracker;
const mpvResult = tracker const mpvResult = tracker
? (() => { ? (() => {
@@ -4943,7 +4990,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
})() })()
: null; : null;
if (!mpvResult) return; if (!mpvResult) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) { if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi'); logger.warn('Failed to set overlay owner via koffi');
} }

View File

@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
) { ) {
return (): UpdateVisibleOverlayBoundsMainDeps => ({ return (): UpdateVisibleOverlayBoundsMainDeps => ({
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry), setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
}); });
} }

View File

@@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => {
assert.deepEqual(calls, [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', () => { test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = []; const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({ const ensureLevel = createEnsureOverlayWindowLevelHandler({

View File

@@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types';
export function createUpdateVisibleOverlayBoundsHandler(deps: { export function createUpdateVisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (geometry: WindowGeometry) => void; setOverlayWindowBounds: (geometry: WindowGeometry) => void;
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
}) { }) {
return (geometry: WindowGeometry): void => { return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds(geometry); deps.setOverlayWindowBounds(geometry);
deps.afterSetOverlayWindowBounds?.(geometry);
}; };
} }

View File

@@ -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<string, Array<(event: unknown) => 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<string, Array<() => void>>();
const documentListeners = new Map<string, Array<(event: unknown) => 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', () => { test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const originalWindow = globalThis.window; const originalWindow = globalThis.window;

View File

@@ -9,6 +9,9 @@ import {
isYomitanPopupIframe, isYomitanPopupIframe,
} from '../yomitan-popup.js'; } from '../yomitan-popup.js';
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
export function createMouseHandlers( export function createMouseHandlers(
ctx: RendererContext, ctx: RendererContext,
options: { options: {
@@ -35,6 +38,7 @@ export function createMouseHandlers(
let pausedByYomitanPopup = false; let pausedByYomitanPopup = false;
let lastPointerPosition: { clientX: number; clientY: number } | null = null; let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false; let pendingPointerResync = false;
let suppressDirectHoverEnterSource: string | null = null;
function getPopupVisibilityFromDom(): boolean { function getPopupVisibilityFromDom(): boolean {
return typeof document !== 'undefined' && isYomitanPopupVisible(document); return typeof document !== 'undefined' && isYomitanPopupVisible(document);
@@ -142,6 +146,7 @@ export function createMouseHandlers(
return; return;
} }
suppressDirectHoverEnterSource = null;
const wasOverSubtitle = ctx.state.isOverSubtitle; const wasOverSubtitle = ctx.state.isOverSubtitle;
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains( const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
'secondary-sub-hover-active', 'secondary-sub-hover-active',
@@ -149,7 +154,7 @@ export function createMouseHandlers(
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY); const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
if (!wasOverSubtitle && hoverState.isOverSubtitle) { if (!wasOverSubtitle && hoverState.isOverSubtitle) {
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle); void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
return; 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; const pointerPosition = lastPointerPosition;
pendingPointerResync = false; pendingPointerResync = false;
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
if (pointerPosition) { if (pointerPosition) {
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY); syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
} else { } else {
@@ -288,7 +297,15 @@ export function createMouseHandlers(
syncOverlayMouseIgnoreState(ctx); syncOverlayMouseIgnoreState(ctx);
} }
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> { async function handleMouseEnter(
_event?: MouseEvent,
showSecondaryHover = false,
source: 'direct' | 'tracked-pointer' = 'direct',
): Promise<void> {
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
return;
}
ctx.state.isOverSubtitle = true; ctx.state.isOverSubtitle = true;
if (showSecondaryHover) { if (showSecondaryHover) {
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active'); ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
@@ -386,6 +403,10 @@ export function createMouseHandlers(
function setupResizeHandler(): void { function setupResizeHandler(): void {
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
options.applyYPercent(options.getCurrentYPercent()); options.applyYPercent(options.getCurrentYPercent());
resyncPointerInteractionState({
allowInteractiveFallback: false,
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
});
}); });
} }
@@ -404,7 +425,10 @@ export function createMouseHandlers(
if (document.visibilityState !== 'visible') { if (document.visibilityState !== 'visible') {
return; return;
} }
resyncPointerInteractionState({ allowInteractiveFallback: false }); resyncPointerInteractionState({
allowInteractiveFallback: false,
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
});
}); });
} }

View File

@@ -216,6 +216,7 @@ export function clearOverlayOwner(overlayHwnd: number): void {
} }
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void { export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE); const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE);
const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0; const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0;

View File

@@ -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 { export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean {
try { try {
const win32 = require('./win32') as typeof import('./win32'); const win32 = require('./win32') as typeof import('./win32');