mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void>((resolve) => setTimeout(resolve, 60));
|
||||
assert.equal(getOpacity(), 1);
|
||||
assert.equal(syncWindowsZOrderCalls, 2);
|
||||
assert.ok(calls.includes('opacity:1'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
50
src/main.ts
50
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<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | 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<boolean> {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
||||
) {
|
||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
@@ -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<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;
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user