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 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
50
src/main.ts
50
src/main.ts
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user