refactor(main): extract visible-overlay platform interaction runtime from main.ts

This commit is contained in:
2026-06-11 23:55:48 -07:00
parent 8f362063dd
commit b9fe555b94
3 changed files with 909 additions and 649 deletions
+92 -645
View File
@@ -51,22 +51,7 @@ import {
resolveLinuxVisibleOverlayWindowModeAction, resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode, type LinuxVisibleOverlayWindowMode,
} from './main/runtime/linux-visible-overlay-window-mode'; } from './main/runtime/linux-visible-overlay-window-mode';
import { import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
ensureLinuxOverlayZOrderKeepAliveLoop,
shouldRunLinuxOverlayZOrderKeepAlive,
tickLinuxOverlayZOrderKeepAlive,
} from './main/runtime/linux-overlay-zorder-keepalive';
import {
applyLinuxOverlayInputShape,
applyLinuxOverlayPointerInteractionMousePassthrough,
ensureLinuxOverlayPointerInteractionLoop,
type ForegroundSuppressionGraceState,
mapOverlayMeasurementForPointerInteraction,
resolveForegroundSuppressionWithGrace,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction,
} from './main/runtime/linux-overlay-pointer-interaction';
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus'; import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff'; import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state'; import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state';
@@ -91,7 +76,7 @@ protocol.registerSchemesAsPrivileged([
]); ]);
import * as fs from 'fs'; import * as fs from 'fs';
import { execFile, spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer'; import { MecabTokenizer } from './mecab-tokenizer';
@@ -128,15 +113,7 @@ import {
resolveDefaultLogFilePath, resolveDefaultLogFilePath,
} from './shared/log-files'; } from './shared/log-files';
import { createFatalErrorReporter } from './main/fatal-error'; import { createFatalErrorReporter } from './main/fatal-error';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { ensureWindowsOverlayTransparency } from './window-trackers/windows-helper';
import {
bindWindowsOverlayAboveMpv,
clearWindowsOverlayOwner,
ensureWindowsOverlayTransparency,
findWindowsMpvTargetWindowHandle,
getWindowsForegroundProcessName,
setWindowsOverlayOwner,
} from './window-trackers/windows-helper';
import { import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime, commandNeedsOverlayRuntime,
@@ -346,7 +323,6 @@ import {
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore, setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
showMpvOsdRuntime, showMpvOsdRuntime,
startOverlayWindowTracker as startOverlayWindowTrackerCore,
tokenizeSubtitle as tokenizeSubtitleCore, tokenizeSubtitle as tokenizeSubtitleCore,
triggerFieldGrouping as triggerFieldGroupingCore, triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings, upsertYomitanDictionarySettings,
@@ -503,7 +479,6 @@ import {
} from './main/jlpt-runtime'; } from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime'; import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup'; import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
@@ -519,8 +494,8 @@ import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/characte
import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate'; import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape';
import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime'; import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime';
import { createVisibleOverlayInteractionRuntime } from './main/runtime/visible-overlay-interaction-runtime';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { import {
@@ -545,10 +520,7 @@ import {
createCreateJellyfinSetupWindowHandler, createCreateJellyfinSetupWindowHandler,
} from './main/runtime/setup-window-factory'; } from './main/runtime/setup-window-factory';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
hasLiveSeparateWindow,
shouldSuppressVisibleOverlayRaiseForSeparateWindow,
} from './main/runtime/settings-window-z-order';
import { import {
isSameYoutubeMediaPath, isSameYoutubeMediaPath,
isYoutubeMediaPath, isYoutubeMediaPath,
@@ -1774,7 +1746,6 @@ let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed';
let linuxTrackedMpvFullscreen = false; let linuxTrackedMpvFullscreen = false;
let linuxTrackedMpvFullscreenChangedAtMs = 0; let linuxTrackedMpvFullscreenChangedAtMs = 0;
let linuxVisibleOverlayOwnerBindingKey: string | null = null; let linuxVisibleOverlayOwnerBindingKey: string | null = null;
const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise<void>>();
let linuxVisibleOverlayWindowModeSwitchToken = 0; let linuxVisibleOverlayWindowModeSwitchToken = 0;
let subtitleSidebarRequestedOpen = false; let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
@@ -2367,14 +2338,18 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible, getForceMousePassthrough: () => appState.statsOverlayVisible,
getNonNativeInputRegionActive: () => getNonNativeInputRegionActive: () =>
process.platform === 'linux' && linuxOverlayInputShapeActive, process.platform === 'linux' &&
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
getSuspendVisibleOverlay: () => appState.statsOverlayVisible, getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive, getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(),
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getLastKnownWindowsForegroundProcessName: () =>
visibleOverlayInteractionRuntime.getLastWindowsVisibleOverlayForegroundProcessName(),
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive, getMacOSForegroundProbeActive: () =>
visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown; appState.trackerNotReadyWarningShown = shown;
@@ -2420,141 +2395,73 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
})(), })(),
); );
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime({
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; overlayManager: {
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; getMainWindow: () => overlayManager.getMainWindow(),
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500; },
// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing overlayContentMeasurementStore: {
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the clear: (layer) => overlayContentMeasurementStore.clear(layer),
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s). getLatestByLayer: (layer) => overlayContentMeasurementStore.getLatestByLayer(layer),
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; },
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500; logger: {
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; info: (message, ...args) => logger.info(message, ...args),
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; warn: (message, ...args) => logger.warn(message, ...args),
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; debug: (message, ...args) => logger.debug(message, ...args),
let windowsVisibleOverlayZOrderSyncInFlight = false; },
let windowsVisibleOverlayZOrderSyncQueued = false; updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null; getModalInputExclusive: () => overlayModalInputState.getModalInputExclusive(),
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; getStatsOverlayVisible: () => appState.statsOverlayVisible,
let lastWindowsVisibleOverlayBlurredAtMs = 0; setStatsOverlayVisible: (visible) => {
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = {
lossSinceMs: null,
};
let visibleOverlayInteractionActive = false;
let linuxOverlayInputShapeActive = false;
let linuxVisibleOverlayStartupInputPrimed = false;
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
// moves off measured subtitle/sidebar rects onto the popup.
let linuxOverlayInteractiveHint = false;
let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => {
appState.statsOverlayVisible = visible; appState.statsOverlayVisible = visible;
}, },
resetVisibleOverlayInteraction: () => { getWindowTracker: () => appState.windowTracker,
visibleOverlayInteractionActive = false; setWindowTracker: (tracker) => {
appState.windowTracker = tracker;
}, },
getMainWindow: () => overlayManager.getMainWindow(), setTrackerNotReadyWarningShown: (shown) => {
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), appState.trackerNotReadyWarningShown = shown;
},
getMpvSocketPath: () => appState.mpvSocketPath,
getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
},
bindVisibleOverlayToTrackedX11Window: (window) => bindVisibleOverlayToTrackedX11Window(window),
updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry),
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
getOverlayWindows: () => getOverlayWindows(),
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
resetLastOverlayWindowGeometry: () => overlayGeometryRuntime.resetLastOverlayWindowGeometry(),
enforceOverlayLayerOrder: () => {
enforceOverlayLayerOrder();
},
getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(),
}); });
function handleStatsOverlayVisibilityChanged(visible: boolean): void {
visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible);
}
function resetVisibleOverlayInputState(): void { function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false; visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
linuxOverlayInputShapeActive = false;
resetLinuxVisibleOverlayStartupInputPrimer();
linuxOverlayInteractiveHint = false;
overlayContentMeasurementStore.clear('visible');
const mainWindow = overlayManager.getMainWindow();
if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) {
restoreLinuxOverlayWindowShape(mainWindow);
}
} }
function restoreVisibleOverlayWindowShapeForShow(): void { function restoreVisibleOverlayWindowShapeForShow(): void {
if (process.platform !== 'linux') { visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow();
return;
}
restoreLinuxOverlayWindowShape(overlayManager.getMainWindow());
}
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
visibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
clearTimeout(timeout);
}
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 getNativeWindowHandleDecimal(window: BrowserWindow): string { function getNativeWindowHandleDecimal(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle(); return visibleOverlayInteractionRuntime.getNativeWindowHandleDecimal(window);
return handle.length >= 8
? handle.readBigUInt64LE(0).toString()
: BigInt(handle.readUInt32LE(0)).toString();
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
return getNativeWindowHandleDecimal(window);
} }
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number { function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
const handle = window.getNativeWindowHandle(); return visibleOverlayInteractionRuntime.getWindowsNativeWindowHandleNumber(window);
return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
} }
function enqueueVisibleOverlayX11OwnerBindingOperation( function enqueueVisibleOverlayX11OwnerBindingOperation(
@@ -2562,540 +2469,79 @@ function enqueueVisibleOverlayX11OwnerBindingOperation(
args: string[], args: string[],
onError?: (error: Error) => void, onError?: (error: Error) => void,
): void { ): void {
const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve(); visibleOverlayInteractionRuntime.enqueueVisibleOverlayX11OwnerBindingOperation(
const operation = previous window,
.catch(() => {}) args,
.then( onError,
() => );
new Promise<void>((resolve) => {
if (window.isDestroyed()) {
resolve();
return;
}
execFile('xprop', args, { timeout: 1500 }, (error) => {
if (error) {
onError?.(error);
}
resolve();
});
}),
);
const queued = operation.finally(() => {
if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) {
linuxVisibleOverlayOwnerBindingQueues.delete(window);
}
});
linuxVisibleOverlayOwnerBindingQueues.set(window, queued);
} }
function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void { function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void {
if (window.isDestroyed()) return; visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window);
enqueueVisibleOverlayX11OwnerBindingOperation(window, [
'-id',
getNativeWindowHandleDecimal(window),
'-remove',
'WM_TRANSIENT_FOR',
]);
}
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
if (process.platform !== 'win32') {
return null;
}
try {
if (targetMpvSocketPath) {
const windowTracker = appState.windowTracker as {
getTargetWindowHandle?: () => number | null;
} | null;
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
return trackedHandle;
}
return null;
}
return findWindowsMpvTargetWindowHandle();
} catch {
return null;
}
} }
function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) { function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { return visibleOverlayInteractionRuntime.createOverlayWindowTracker(override, targetMpvSocketPath);
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
} }
function bindVisibleOverlayOwner(): void { function bindVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow(); visibleOverlayInteractionRuntime.bindVisibleOverlayOwner();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'linux') {
bindVisibleOverlayToTrackedX11Window(mainWindow);
return;
}
if (process.platform !== 'win32') return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetSocketPath = appState.mpvSocketPath;
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
return;
}
if (targetSocketPath) {
return;
}
const tracker = appState.windowTracker;
const mpvResult = tracker
? (() => {
try {
const win32 =
require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
} }
function releaseVisibleOverlayOwner(): void { function releaseVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow(); visibleOverlayInteractionRuntime.releaseVisibleOverlayOwner();
if (process.platform === 'linux') {
linuxVisibleOverlayOwnerBindingKey = null;
if (mainWindow && !mainWindow.isDestroyed()) {
clearVisibleOverlayX11OwnerBinding(mainWindow);
}
return;
}
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwner(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
}
function startOverlayWindowTrackerForCurrentSocket(): void {
startOverlayWindowTrackerCore({
backendOverride: appState.backendOverride,
getMpvSocketPath: () => appState.mpvSocketPath,
createWindowTracker: createOverlayWindowTracker,
setWindowTracker: (tracker) => {
appState.windowTracker = tracker;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
getOverlayWindows: () => getOverlayWindows(),
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
bindOverlayOwner: () => bindVisibleOverlayOwner(),
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
});
} }
function retargetOverlayWindowTrackerForMpvSocket( function retargetOverlayWindowTrackerForMpvSocket(
nextSocketPath: string, nextSocketPath: string,
previousSocketPath: string, previousSocketPath: string,
): void { ): void {
if (nextSocketPath === previousSocketPath || !appState.overlayRuntimeInitialized) { visibleOverlayInteractionRuntime.retargetOverlayWindowTrackerForMpvSocket(
return; nextSocketPath,
} previousSocketPath,
const previousTracker = appState.windowTracker;
if (previousTracker) {
try {
previousTracker.stop();
} catch (error) {
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
}
}
releaseVisibleOverlayOwner();
appState.windowTracker = null;
appState.trackerNotReadyWarningShown = false;
overlayGeometryRuntime.resetLastOverlayWindowGeometry();
startOverlayWindowTrackerForCurrentSocket();
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
overlayShortcutsRuntime.syncOverlayShortcuts();
logger.info(
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
); );
} }
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible() ||
!overlayManager.getVisibleOverlayVisible()
) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
return false;
}
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
return false;
}
function requestWindowsVisibleOverlayZOrderSync(): void { function requestWindowsVisibleOverlayZOrderSync(): void {
if (process.platform !== 'win32') { visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync();
return;
}
if (windowsVisibleOverlayZOrderSyncInFlight) {
windowsVisibleOverlayZOrderSyncQueued = true;
return;
}
windowsVisibleOverlayZOrderSyncInFlight = true;
void syncWindowsVisibleOverlayToMpvZOrder()
.catch((error) => {
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
})
.finally(() => {
windowsVisibleOverlayZOrderSyncInFlight = false;
if (!windowsVisibleOverlayZOrderSyncQueued) {
return;
}
windowsVisibleOverlayZOrderSyncQueued = false;
requestWindowsVisibleOverlayZOrderSync();
});
} }
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void { function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
if (process.platform !== 'win32') { visibleOverlayInteractionRuntime.scheduleWindowsVisibleOverlayZOrderSyncBurst();
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 visibleOverlayInteractionRuntime.hasWindowsVisibleOverlayFocusHandoffGrace();
process.platform === 'win32' &&
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
);
}
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
const overlayFocused = mainWindow.isFocused();
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
return !overlayFocused && !trackerFocused;
}
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
lastWindowsVisibleOverlayForegroundProcessName = null;
return;
}
const processName = getWindowsForegroundProcessName();
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
if (normalizedProcessName !== previousProcessName) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
requestWindowsVisibleOverlayZOrderSync();
}
}
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
return;
}
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
} }
function clearWindowsVisibleOverlayForegroundPollLoop(): void { function clearWindowsVisibleOverlayForegroundPollLoop(): void {
if (windowsVisibleOverlayForegroundPollInterval === null) { visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
return;
}
clearInterval(windowsVisibleOverlayForegroundPollInterval);
windowsVisibleOverlayForegroundPollInterval = null;
} }
function scheduleVisibleOverlayBlurRefresh(): void { function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32' && process.platform !== 'darwin') { visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
return;
}
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
startMacOSVisibleOverlayForegroundProbe();
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
function getLinuxOverlayPointerMeasurement() {
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
return mapOverlayMeasurementForPointerInteraction(measurement);
}
function shouldSuspendLinuxOverlayPointerInteraction(): boolean {
return overlayModalInputState.getModalInputExclusive() || appState.statsOverlayVisible;
}
function shouldSuppressLinuxOverlayPointerInteraction(): boolean {
return resolveForegroundSuppressionWithGrace({
hasForegroundSeparateWindow: hasLiveSeparateWindow(getOverlayForegroundSeparateWindows()),
isTrackingMpvWindow: Boolean(appState.windowTracker?.isTracking()),
isMpvWindowFocused: appState.windowTracker?.isTargetWindowFocused?.() !== false,
isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true,
nowMs: Date.now(),
graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS,
state: linuxPointerForegroundSuppressionGrace,
});
}
function shouldUseLinuxOverlayInputShape(): boolean {
// Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so
// it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped
// region). There is no input-only region API on Linux, so selective hit-testing is handled by
// the main-process cursor poll instead. Keep this off to avoid clipping the overlay.
return false;
}
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
return (
process.platform === 'linux' &&
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
);
}
function clearLinuxVisibleOverlayStartupInputGrace(): void {
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
} }
function resetLinuxVisibleOverlayStartupInputPrimer(): void { function resetLinuxVisibleOverlayStartupInputPrimer(): void {
linuxVisibleOverlayStartupInputPrimed = false; visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();
clearLinuxVisibleOverlayStartupInputGrace();
} }
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
if (!shouldUseLinuxOverlayInputShape()) { return visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement();
linuxOverlayInputShapeActive = false;
return false;
}
const result = applyLinuxOverlayInputShape({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
});
linuxOverlayInputShapeActive = result.active;
return result.handled;
}
function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
visibleOverlayInteractionActive = active;
if (
process.platform === 'linux' &&
applyLinuxOverlayPointerInteractionMousePassthrough({
active,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
})
) {
return;
}
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
} }
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void { function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
if (process.platform !== 'linux') return; visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
if (linuxVisibleOverlayStartupInputPrimed) return;
if (shouldUseLinuxOverlayInputShape()) return;
if (
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
})
) {
return;
}
linuxVisibleOverlayStartupInputPrimed = true;
linuxVisibleOverlayStartupInputGraceUntilMs =
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
updateLinuxOverlayPointerInteractionActive(true);
} }
const linuxOverlayZOrderKeepAliveDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
isTrackingMpvWindow: () => Boolean(appState.windowTracker?.isTracking()),
isMpvWindowFocused: () => appState.windowTracker?.isTargetWindowFocused?.() !== false,
isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true,
shouldSuppressReassert: () =>
overlayModalInputState.getModalInputExclusive() ||
appState.statsOverlayVisible ||
hasLiveSeparateWindow(getOverlayForegroundSeparateWindows()) ||
(visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true),
raiseMpvWindow: () => {
if (
lastLinuxVisibleOverlayFollowedMpvAtMs > 0 &&
Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <=
LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
) {
return Promise.resolve(false);
}
lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now();
return appState.windowTracker?.raiseTargetWindow?.() ?? Promise.resolve(false);
},
releaseOverlayLayerOrder: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.setAlwaysOnTop(false);
mainWindow.setFullScreen?.(false);
mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
if (linuxVisibleOverlayWindowMode === 'fullscreen-override' && mainWindow.isVisible()) {
mainWindow.hide();
}
},
enforceOverlayLayerOrder: () => {
enforceOverlayLayerOrder();
},
focusOverlayWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return;
mainWindow.focus();
},
};
function requestLinuxOverlayZOrderFollow(): void { function requestLinuxOverlayZOrderFollow(): void {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) return; visibleOverlayInteractionRuntime.requestLinuxOverlayZOrderFollow();
void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => {
logger.debug(
'Failed to follow tracked mpv behind focused overlay:',
error instanceof Error ? error.message : String(error),
);
});
} }
ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps);
const linuxOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () =>
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () =>
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
getInteractionActive: () => visibleOverlayInteractionActive,
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
};
function tickLinuxOverlayPointerInteractionNow(): void { function tickLinuxOverlayPointerInteractionNow(): void {
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) { visibleOverlayInteractionRuntime.tickLinuxOverlayPointerInteractionNow();
return;
}
tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps);
} }
ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps);
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
{ {
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
@@ -4932,7 +4378,8 @@ const {
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) => syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen), syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen),
getOverlayInteractionActive: () => getOverlayInteractionActive: () =>
visibleOverlayInteractionActive || linuxOverlayInputShapeActive, visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() ||
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
}, },
cancelLinuxMpvFullscreenOverlayRefreshBurst, cancelLinuxMpvFullscreenOverlayRefreshBurst,
@@ -5925,13 +5372,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
if (!mainWindow || senderWindow !== mainWindow) { if (!mainWindow || senderWindow !== mainWindow) {
return; return;
} }
if (visibleOverlayInteractionActive === active) { if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
} }
return; return;
} }
visibleOverlayInteractionActive = active; visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
onOverlayInteractiveHint: (interactive, senderWindow) => { onOverlayInteractiveHint: (interactive, senderWindow) => {
@@ -5939,7 +5386,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
if (!mainWindow || senderWindow !== mainWindow) { if (!mainWindow || senderWindow !== mainWindow) {
return; return;
} }
linuxOverlayInteractiveHint = interactive; visibleOverlayInteractionRuntime.setLinuxOverlayInteractiveHint(interactive);
applyLinuxOverlayInputShapeFromLatestMeasurement(); applyLinuxOverlayInputShapeFromLatestMeasurement();
}, },
handleOverlayNotificationAction: (notificationId, actionId, noteId) => { handleOverlayNotificationAction: (notificationId, actionId, noteId) => {
+7 -4
View File
@@ -373,11 +373,12 @@ test('stats server Yomitan note creation honors configured Anki server override
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource(); const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match( const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/, /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body; )?.groups?.body;
const resetBlock = source.match( const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/, /function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -506,8 +507,9 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => { test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
const source = readMainSource(); const source = readMainSource();
const resetBlock = source.match( const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/, const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
const setBlock = source.match( const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/, /function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
@@ -517,6 +519,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.ok(setBlock); assert.ok(setBlock);
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/); assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
@@ -0,0 +1,810 @@
import { type BrowserWindow, screen } from 'electron';
import { execFile } from 'node:child_process';
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
import type { BaseWindowTracker } from '../../window-trackers';
import {
bindWindowsOverlayAboveMpv,
clearWindowsOverlayOwner,
findWindowsMpvTargetWindowHandle,
getWindowsForegroundProcessName,
setWindowsOverlayOwner,
} from '../../window-trackers/windows-helper';
import {
applyLinuxOverlayInputShape,
applyLinuxOverlayPointerInteractionMousePassthrough,
ensureLinuxOverlayPointerInteractionLoop,
type ForegroundSuppressionGraceState,
mapOverlayMeasurementForPointerInteraction,
resolveForegroundSuppressionWithGrace,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction,
} from './linux-overlay-pointer-interaction';
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
import {
ensureLinuxOverlayZOrderKeepAliveLoop,
shouldRunLinuxOverlayZOrderKeepAlive,
tickLinuxOverlayZOrderKeepAlive,
} from './linux-overlay-zorder-keepalive';
import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
import { hasLiveSeparateWindow } from './settings-window-z-order';
export interface VisibleOverlayInteractionRuntimeDeps {
overlayManager: {
getMainWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
};
overlayContentMeasurementStore: {
clear: (layer: 'visible') => void;
getLatestByLayer: (layer: 'visible') => OverlayContentMeasurement | null;
};
logger: {
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug: (message: string, ...args: unknown[]) => void;
};
updateVisibleOverlayVisibility: () => void;
getModalInputExclusive: () => boolean;
getStatsOverlayVisible: () => boolean;
setStatsOverlayVisible: (visible: boolean) => void;
getWindowTracker: () => BaseWindowTracker | null;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
getMpvSocketPath: () => string;
getBackendOverride: () => string | null;
getInitialArgs: () => CliArgs | null;
getOverlayRuntimeInitialized: () => boolean;
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
bindVisibleOverlayToTrackedX11Window: (window: BrowserWindow) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
refreshCurrentSubtitle: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
resetLastOverlayWindowGeometry: () => void;
enforceOverlayLayerOrder: () => void;
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
}
export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInteractionRuntimeDeps) {
const { overlayManager, overlayContentMeasurementStore, logger } = deps;
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 LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500;
// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
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;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = {
lossSinceMs: null,
};
let visibleOverlayInteractionActive = false;
let linuxOverlayInputShapeActive = false;
let linuxVisibleOverlayStartupInputPrimed = false;
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
// moves off measured subtitle/sidebar rects onto the popup.
let linuxOverlayInteractiveHint = false;
let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise<void>>();
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => {
deps.setStatsOverlayVisible(visible);
},
resetVisibleOverlayInteraction: () => {
visibleOverlayInteractionActive = false;
},
getMainWindow: () => overlayManager.getMainWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
});
function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false;
linuxOverlayInputShapeActive = false;
resetLinuxVisibleOverlayStartupInputPrimer();
linuxOverlayInteractiveHint = false;
overlayContentMeasurementStore.clear('visible');
const mainWindow = overlayManager.getMainWindow();
if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) {
restoreLinuxOverlayWindowShape(mainWindow);
}
}
function restoreVisibleOverlayWindowShapeForShow(): void {
if (process.platform !== 'linux') {
return;
}
restoreLinuxOverlayWindowShape(overlayManager.getMainWindow());
}
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
visibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayZOrderRetryTimeouts = [];
}
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
if (token !== macOSVisibleOverlayForegroundProbeToken) {
return;
}
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
macOSVisibleOverlayForegroundProbeTimeout = null;
}
if (!macOSVisibleOverlayForegroundProbeActive) {
return;
}
macOSVisibleOverlayForegroundProbeActive = false;
deps.updateVisibleOverlayVisibility();
}
function startMacOSVisibleOverlayForegroundProbe(): void {
if (process.platform !== 'darwin') {
return;
}
const tracker = deps.getWindowTracker();
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 getNativeWindowHandleDecimal(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
? handle.readBigUInt64LE(0).toString()
: BigInt(handle.readUInt32LE(0)).toString();
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
return getNativeWindowHandleDecimal(window);
}
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
const handle = window.getNativeWindowHandle();
return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
}
function enqueueVisibleOverlayX11OwnerBindingOperation(
window: BrowserWindow,
args: string[],
onError?: (error: Error) => void,
): void {
const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve();
const operation = previous
.catch(() => {})
.then(
() =>
new Promise<void>((resolve) => {
if (window.isDestroyed()) {
resolve();
return;
}
execFile('xprop', args, { timeout: 1500 }, (error) => {
if (error) {
onError?.(error);
}
resolve();
});
}),
);
const queued = operation.finally(() => {
if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) {
linuxVisibleOverlayOwnerBindingQueues.delete(window);
}
});
linuxVisibleOverlayOwnerBindingQueues.set(window, queued);
}
function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void {
if (window.isDestroyed()) return;
enqueueVisibleOverlayX11OwnerBindingOperation(window, [
'-id',
getNativeWindowHandleDecimal(window),
'-remove',
'WM_TRANSIENT_FOR',
]);
}
function resolveWindowsOverlayBindTargetHandle(
targetMpvSocketPath?: string | null,
): number | null {
if (process.platform !== 'win32') {
return null;
}
try {
if (targetMpvSocketPath) {
const windowTracker = deps.getWindowTracker() as {
getTargetWindowHandle?: () => number | null;
} | null;
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
return trackedHandle;
}
return null;
}
return findWindowsMpvTargetWindowHandle();
} catch {
return null;
}
}
function createOverlayWindowTracker(
override?: string | null,
targetMpvSocketPath?: string | null,
) {
const initialArgs = deps.getInitialArgs();
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
}
function bindVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'linux') {
deps.bindVisibleOverlayToTrackedX11Window(mainWindow);
return;
}
if (process.platform !== 'win32') return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetSocketPath = deps.getMpvSocketPath();
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
return;
}
if (targetSocketPath) {
return;
}
const tracker = deps.getWindowTracker();
const mpvResult = tracker
? (() => {
try {
const win32 =
require('../../window-trackers/win32') as typeof import('../../window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
}
function releaseVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow();
if (process.platform === 'linux') {
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
if (mainWindow && !mainWindow.isDestroyed()) {
clearVisibleOverlayX11OwnerBinding(mainWindow);
}
return;
}
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwner(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
}
function startOverlayWindowTrackerForCurrentSocket(): void {
startOverlayWindowTrackerCore({
backendOverride: deps.getBackendOverride(),
getMpvSocketPath: () => deps.getMpvSocketPath(),
createWindowTracker: createOverlayWindowTracker,
setWindowTracker: (tracker) => {
deps.setWindowTracker(tracker);
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => {
deps.refreshCurrentSubtitle();
},
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
bindOverlayOwner: () => bindVisibleOverlayOwner(),
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
});
}
function retargetOverlayWindowTrackerForMpvSocket(
nextSocketPath: string,
previousSocketPath: string,
): void {
if (nextSocketPath === previousSocketPath || !deps.getOverlayRuntimeInitialized()) {
return;
}
const previousTracker = deps.getWindowTracker();
if (previousTracker) {
try {
previousTracker.stop();
} catch (error) {
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
}
}
releaseVisibleOverlayOwner();
deps.setWindowTracker(null);
deps.setTrackerNotReadyWarningShown(false);
deps.resetLastOverlayWindowGeometry();
startOverlayWindowTrackerForCurrentSocket();
deps.updateVisibleOverlayVisibility();
deps.syncOverlayShortcuts();
logger.info(
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
);
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible() ||
!overlayManager.getVisibleOverlayVisible()
) {
return false;
}
const windowTracker = deps.getWindowTracker();
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
return false;
}
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(deps.getMpvSocketPath());
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
return false;
}
function requestWindowsVisibleOverlayZOrderSync(): void {
if (process.platform !== 'win32') {
return;
}
if (windowsVisibleOverlayZOrderSyncInFlight) {
windowsVisibleOverlayZOrderSyncQueued = true;
return;
}
windowsVisibleOverlayZOrderSyncInFlight = true;
void syncWindowsVisibleOverlayToMpvZOrder()
.catch((error) => {
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
})
.finally(() => {
windowsVisibleOverlayZOrderSyncInFlight = false;
if (!windowsVisibleOverlayZOrderSyncQueued) {
return;
}
windowsVisibleOverlayZOrderSyncQueued = false;
requestWindowsVisibleOverlayZOrderSync();
});
}
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' &&
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
);
}
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return false;
}
const windowTracker = deps.getWindowTracker();
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
const overlayFocused = mainWindow.isFocused();
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
return !overlayFocused && !trackerFocused;
}
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
lastWindowsVisibleOverlayForegroundProcessName = null;
return;
}
const processName = getWindowsForegroundProcessName();
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
if (normalizedProcessName !== previousProcessName) {
deps.updateVisibleOverlayVisibility();
}
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
requestWindowsVisibleOverlayZOrderSync();
}
}
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
return;
}
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
if (windowsVisibleOverlayForegroundPollInterval === null) {
return;
}
clearInterval(windowsVisibleOverlayForegroundPollInterval);
windowsVisibleOverlayForegroundPollInterval = null;
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
startMacOSVisibleOverlayForegroundProbe();
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
deps.updateVisibleOverlayVisibility();
}, delayMs);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
function getLinuxOverlayPointerMeasurement() {
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
return mapOverlayMeasurementForPointerInteraction(measurement);
}
function shouldSuspendLinuxOverlayPointerInteraction(): boolean {
return deps.getModalInputExclusive() || deps.getStatsOverlayVisible();
}
function shouldSuppressLinuxOverlayPointerInteraction(): boolean {
return resolveForegroundSuppressionWithGrace({
hasForegroundSeparateWindow: hasLiveSeparateWindow(
deps.getOverlayForegroundSeparateWindows(),
),
isTrackingMpvWindow: Boolean(deps.getWindowTracker()?.isTracking()),
isMpvWindowFocused: deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true,
nowMs: Date.now(),
graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS,
state: linuxPointerForegroundSuppressionGrace,
});
}
function shouldUseLinuxOverlayInputShape(): boolean {
// Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so
// it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped
// region). There is no input-only region API on Linux, so selective hit-testing is handled by
// the main-process cursor poll instead. Keep this off to avoid clipping the overlay.
return false;
}
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
return (
process.platform === 'linux' &&
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
);
}
function clearLinuxVisibleOverlayStartupInputGrace(): void {
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
}
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
linuxVisibleOverlayStartupInputPrimed = false;
clearLinuxVisibleOverlayStartupInputGrace();
}
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
if (!shouldUseLinuxOverlayInputShape()) {
linuxOverlayInputShapeActive = false;
return false;
}
const result = applyLinuxOverlayInputShape({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
});
linuxOverlayInputShapeActive = result.active;
return result.handled;
}
function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
visibleOverlayInteractionActive = active;
if (
process.platform === 'linux' &&
applyLinuxOverlayPointerInteractionMousePassthrough({
active,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
})
) {
return;
}
deps.updateVisibleOverlayVisibility();
}
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
if (process.platform !== 'linux') return;
if (linuxVisibleOverlayStartupInputPrimed) return;
if (shouldUseLinuxOverlayInputShape()) return;
if (
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
})
) {
return;
}
linuxVisibleOverlayStartupInputPrimed = true;
linuxVisibleOverlayStartupInputGraceUntilMs =
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
updateLinuxOverlayPointerInteractionActive(true);
}
const linuxOverlayZOrderKeepAliveDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
isTrackingMpvWindow: () => Boolean(deps.getWindowTracker()?.isTracking()),
isMpvWindowFocused: () => deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true,
shouldSuppressReassert: () =>
deps.getModalInputExclusive() ||
deps.getStatsOverlayVisible() ||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows()) ||
(visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true),
raiseMpvWindow: () => {
if (
lastLinuxVisibleOverlayFollowedMpvAtMs > 0 &&
Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <=
LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
) {
return Promise.resolve(false);
}
lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now();
return deps.getWindowTracker()?.raiseTargetWindow?.() ?? Promise.resolve(false);
},
releaseOverlayLayerOrder: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.setAlwaysOnTop(false);
mainWindow.setFullScreen?.(false);
mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
if (
deps.getLinuxVisibleOverlayWindowMode() === 'fullscreen-override' &&
mainWindow.isVisible()
) {
mainWindow.hide();
}
},
enforceOverlayLayerOrder: () => {
deps.enforceOverlayLayerOrder();
},
focusOverlayWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return;
mainWindow.focus();
},
};
function requestLinuxOverlayZOrderFollow(): void {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) return;
void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => {
logger.debug(
'Failed to follow tracked mpv behind focused overlay:',
error instanceof Error ? error.message : String(error),
);
});
}
ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps);
const linuxOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () =>
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () =>
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
getInteractionActive: () => visibleOverlayInteractionActive,
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
};
function tickLinuxOverlayPointerInteractionNow(): void {
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
return;
}
tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps);
}
ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps);
return {
handleStatsOverlayVisibilityChanged,
resetVisibleOverlayInputState,
restoreVisibleOverlayWindowShapeForShow,
startMacOSVisibleOverlayForegroundProbe,
getNativeWindowHandleDecimal,
getWindowsNativeWindowHandle,
getWindowsNativeWindowHandleNumber,
enqueueVisibleOverlayX11OwnerBindingOperation,
clearVisibleOverlayX11OwnerBinding,
createOverlayWindowTracker,
bindVisibleOverlayOwner,
releaseVisibleOverlayOwner,
startOverlayWindowTrackerForCurrentSocket,
retargetOverlayWindowTrackerForMpvSocket,
requestWindowsVisibleOverlayZOrderSync,
scheduleWindowsVisibleOverlayZOrderSyncBurst,
hasWindowsVisibleOverlayFocusHandoffGrace,
ensureWindowsVisibleOverlayForegroundPollLoop,
clearWindowsVisibleOverlayForegroundPollLoop,
scheduleVisibleOverlayBlurRefresh,
getLinuxOverlayPointerMeasurement,
hasLinuxVisibleOverlayStartupInputGrace,
clearLinuxVisibleOverlayStartupInputGrace,
resetLinuxVisibleOverlayStartupInputPrimer,
applyLinuxOverlayInputShapeFromLatestMeasurement,
updateLinuxOverlayPointerInteractionActive,
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
requestLinuxOverlayZOrderFollow,
tickLinuxOverlayPointerInteractionNow,
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
setVisibleOverlayInteractionActive: (active: boolean) => {
visibleOverlayInteractionActive = active;
},
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
getLastWindowsVisibleOverlayForegroundProcessName: () =>
lastWindowsVisibleOverlayForegroundProcessName,
getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
setLinuxOverlayInteractiveHint: (interactive: boolean) => {
linuxOverlayInteractiveHint = interactive;
},
};
}
export type VisibleOverlayInteractionRuntime = ReturnType<
typeof createVisibleOverlayInteractionRuntime
>;