mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-12 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:
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user