fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause

- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open
This commit is contained in:
2026-05-22 00:31:36 -07:00
parent 3a2d7a282d
commit 1a7f015f4e
13 changed files with 364 additions and 9 deletions
@@ -1213,6 +1213,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
assert.ok(!calls.includes('show'));
});
test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
macOSForegroundProbeActive: true,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+11 -1
View File
@@ -70,6 +70,7 @@ export function updateVisibleOverlayVisibility(args: {
lastKnownWindowsForegroundProcessName?: string | null;
windowsOverlayProcessName?: string | null;
windowsFocusHandoffGraceActive?: boolean;
macOSForegroundProbeActive?: boolean;
trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
@@ -115,6 +116,12 @@ export function updateVisibleOverlayVisibility(args: {
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const shouldPreserveMacOSOverlayDuringForegroundProbe =
args.isMacOSPlatform &&
args.macOSForegroundProbeActive === true &&
!!windowTracker &&
!isTrackedMacOSTargetMinimized &&
(windowTracker.isTracking() || windowTracker.getGeometry() !== null);
const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform &&
canReportMacOSTargetMinimized &&
@@ -124,7 +131,10 @@ export function updateVisibleOverlayVisibility(args: {
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible();
const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
hasTransientMacOSTrackerLoss ||
shouldPreserveMacOSOverlayDuringForegroundProbe ||
!args.isMacOSPlatform ||
!args.windowTracker
? true
: (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel =
+49
View File
@@ -2237,6 +2237,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown;
@@ -2280,6 +2281,7 @@ const 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;
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
@@ -2288,6 +2290,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false;
let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
@@ -2303,6 +2308,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
windowsVisibleOverlayZOrderRetryTimeouts = [];
}
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
if (token !== macOSVisibleOverlayForegroundProbeToken) {
return;
}
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
macOSVisibleOverlayForegroundProbeTimeout = null;
}
if (!macOSVisibleOverlayForegroundProbeActive) {
return;
}
macOSVisibleOverlayForegroundProbeActive = false;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
function startMacOSVisibleOverlayForegroundProbe(): void {
if (process.platform !== 'darwin') {
return;
}
const tracker = appState.windowTracker;
if (!tracker) {
return;
}
macOSVisibleOverlayForegroundProbeActive = true;
const token = ++macOSVisibleOverlayForegroundProbeToken;
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
}
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
void tracker
.refreshNow()
.catch((error) => {
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
})
.finally(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
});
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
@@ -2501,6 +2549,7 @@ function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
startMacOSVisibleOverlayForegroundProbe();
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
+2
View File
@@ -16,6 +16,7 @@ export interface OverlayVisibilityRuntimeDeps {
getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null;
getWindowsFocusHandoffGraceActive?: () => boolean;
getMacOSForegroundProbeActive?: () => boolean;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
@@ -56,6 +57,7 @@ export function createOverlayVisibilityRuntimeService(
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false,
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => {
deps.setTrackerNotReadyWarningShown(shown);
@@ -20,6 +20,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getLastKnownWindowsForegroundProcessName: () => 'mpv',
getWindowsOverlayProcessName: () => 'subminer',
getWindowsFocusHandoffGraceActive: () => true,
getMacOSForegroundProbeActive: () => true,
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
trackerNotReadyWarningShown = shown;
@@ -45,6 +46,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
assert.equal(deps.getMacOSForegroundProbeActive?.(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
deps.setTrackerNotReadyWarningShown(true);
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
@@ -16,6 +16,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false,
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
@@ -8,6 +8,7 @@ import {
createSubtitleSidebarModal,
findActiveSubtitleCueIndex,
} from './subtitle-sidebar.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
@@ -1542,6 +1543,137 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
}
});
test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains open', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: true,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
removeEventListener: () => {},
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
getPlaybackPaused: async () => false,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
} as unknown as ElectronAPI,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
state.autoPauseVideoOnYomitanPopup = true;
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
await modal.openSubtitleSidebarModal();
mpvCommands.length = 0;
await contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
await listener();
}
await contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
assert.equal(state.subtitleSidebarPausedByHover, true);
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
await listener();
}
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.equal(state.subtitleSidebarPausedByHover, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
+58
View File
@@ -1,6 +1,11 @@
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
isYomitanPopupVisible,
} from '../yomitan-popup.js';
const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
@@ -194,6 +199,8 @@ export function createSubtitleSidebarModal(
let disposeDomEvents: (() => void) | null = null;
let subtitleSidebarHovered = false;
let subtitleSidebarFocusedWithin = false;
let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -323,18 +330,65 @@ export function createSubtitleSidebarModal(
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
}
function isYomitanPopupVisibleForSidebar(): boolean {
if (subtitleSidebarYomitanPopupVisible || ctx.state.yomitanPopupVisible) {
return true;
}
if (typeof document === 'undefined') {
return false;
}
return isYomitanPopupVisible(document);
}
function shouldHoldSidebarPauseForYomitanPopup(): boolean {
return (
ctx.state.autoPauseVideoOnYomitanPopup &&
ctx.state.subtitleSidebarPausedByHover &&
isYomitanPopupVisibleForSidebar()
);
}
function resumeSubtitleSidebarHoverPause(): void {
subtitleSidebarHoverRequestId += 1;
if (!ctx.state.subtitleSidebarPausedByHover) {
subtitleSidebarPauseHeldByYomitanPopup = false;
restoreEmbeddedSidebarPassthrough();
return;
}
if (shouldHoldSidebarPauseForYomitanPopup()) {
subtitleSidebarPauseHeldByYomitanPopup = true;
restoreEmbeddedSidebarPassthrough();
return;
}
subtitleSidebarPauseHeldByYomitanPopup = false;
ctx.state.subtitleSidebarPausedByHover = false;
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
restoreEmbeddedSidebarPassthrough();
}
function handleYomitanPopupShown(): void {
subtitleSidebarYomitanPopupVisible = true;
if (ctx.state.autoPauseVideoOnYomitanPopup && ctx.state.subtitleSidebarPausedByHover) {
subtitleSidebarPauseHeldByYomitanPopup = true;
}
}
function handleYomitanPopupHidden(): void {
subtitleSidebarYomitanPopupVisible = false;
if (!subtitleSidebarPauseHeldByYomitanPopup) {
return;
}
subtitleSidebarPauseHeldByYomitanPopup = false;
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
return;
}
resumeSubtitleSidebarHoverPause();
}
function maybeAutoScrollActiveCue(
previousActiveCueIndex: number,
behavior: ScrollBehavior = 'smooth',
@@ -660,8 +714,12 @@ export function createSubtitleSidebarModal(
syncEmbeddedSidebarLayout();
};
window.addEventListener('resize', resizeHandler);
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
disposeDomEvents = () => {
window.removeEventListener('resize', resizeHandler);
window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
disposeDomEvents = null;
};
}
+4
View File
@@ -50,6 +50,10 @@ export abstract class BaseWindowTracker {
abstract start(): void;
abstract stop(): void;
refreshNow(): Promise<void> {
return Promise.resolve();
}
getGeometry(): WindowGeometry | null {
return this.currentGeometry;
}
+29
View File
@@ -359,6 +359,35 @@ test('MacOSWindowTracker marks target unfocused on explicit inactive helper sign
assert.deepEqual(focusChanges, [true, false]);
});
test('MacOSWindowTracker refreshNow immediately samples frontmost mpv state', async () => {
let callIndex = 0;
const outputs = [
{ stdout: '10,20,1280,720,0', stderr: '' },
{ stdout: 'active', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
});
await (tracker as unknown as { refreshNow: () => Promise<void> }).refreshNow();
assert.equal(tracker.isTargetWindowFocused(), false);
await (tracker as unknown as { refreshNow: () => Promise<void> }).refreshNow();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
});
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
let callIndex = 0;
const outputs = [
+20 -8
View File
@@ -196,7 +196,7 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
export class MacOSWindowTracker extends BaseWindowTracker {
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
private pollInFlight = false;
private pollInFlightPromise: Promise<void> | null = null;
private started = false;
private helperPath: string | null = null;
private helperType: 'binary' | 'swift' | null = null;
@@ -357,7 +357,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
return;
}
this.started = true;
this.pollGeometry();
void this.pollGeometry();
}
stop(): void {
@@ -365,6 +365,11 @@ export class MacOSWindowTracker extends BaseWindowTracker {
this.clearScheduledPoll();
}
override refreshNow(): Promise<void> {
this.clearScheduledPoll();
return this.pollGeometry();
}
override isTargetWindowMinimized(): boolean {
return this.targetWindowMinimized;
}
@@ -443,13 +448,19 @@ export class MacOSWindowTracker extends BaseWindowTracker {
}, this.resolveNextPollIntervalMs());
}
private pollGeometry(): void {
if (this.pollInFlight || !this.helperPath || !this.helperType) {
return;
private pollGeometry(): Promise<void> {
if (this.pollInFlightPromise) {
return this.pollInFlightPromise;
}
if (!this.helperPath || !this.helperType) {
return Promise.resolve();
}
this.pollInFlight = true;
void this.runHelper(this.helperPath, this.helperType, this.targetMpvSocketPath)
this.pollInFlightPromise = this.runHelper(
this.helperPath,
this.helperType,
this.targetMpvSocketPath,
)
.then(({ stdout }) => {
const parsed = parseMacOSHelperOutput(stdout || '');
if (parsed) {
@@ -495,8 +506,9 @@ export class MacOSWindowTracker extends BaseWindowTracker {
this.registerTrackingMiss();
})
.finally(() => {
this.pollInFlight = false;
this.pollInFlightPromise = null;
this.scheduleNextPoll();
});
return this.pollInFlightPromise;
}
}