diff --git a/changes/fix-sidebar-yomitan-popup-pause.md b/changes/fix-sidebar-yomitan-popup-pause.md new file mode 100644 index 00000000..fc0dd2ca --- /dev/null +++ b/changes/fix-sidebar-yomitan-popup-pause.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled. diff --git a/changes/macos-overlay-focus-handoff.md b/changes/macos-overlay-focus-handoff.md new file mode 100644 index 00000000..04902fe0 --- /dev/null +++ b/changes/macos-overlay-focus-handoff.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Kept the macOS visible overlay stable when clicking from the overlay back into mpv. diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 6948a97a..72ba5d78 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -1260,6 +1260,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 = { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 69f2f3d9..8d7a2d40 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -71,6 +71,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; @@ -128,6 +129,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 && @@ -137,7 +144,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 = diff --git a/src/main.ts b/src/main.ts index 60f9c6b2..384de6f4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2280,6 +2280,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; @@ -2323,6 +2324,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> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; @@ -2331,6 +2333,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; let visibleOverlayInteractionActive = false; +let macOSVisibleOverlayForegroundProbeActive = false; +let macOSVisibleOverlayForegroundProbeToken = 0; +let macOSVisibleOverlayForegroundProbeTimeout: ReturnType | null = null; const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({ setStatsOverlayVisibleState: (visible) => { @@ -2357,6 +2362,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 @@ -2555,6 +2603,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(() => { diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 0adffe51..7a43e30c 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -17,6 +17,7 @@ export interface OverlayVisibilityRuntimeDeps { getLastKnownWindowsForegroundProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null; getWindowsFocusHandoffGraceActive?: () => boolean; + getMacOSForegroundProbeActive?: () => boolean; getTrackerNotReadyWarningShown: () => boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; @@ -59,6 +60,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); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index f15bb400..fcef4244 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -21,6 +21,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; @@ -47,6 +48,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 }); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index 2445256d..90cfca06 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -17,6 +17,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) => diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index af3d4bf4..721c3463 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -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> = []; + const contentListeners = new Map Promise | void>>(); + const windowListeners = new Map Promise | 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) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + removeEventListener: () => {}, + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + getPlaybackPaused: async () => false, + sendMpvCommand: (command: Array) => { + 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) => { + 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; diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 9f766f52..57e4d6b1 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -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; }; } diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index 15b5d9f5..8947dd4b 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -50,6 +50,10 @@ export abstract class BaseWindowTracker { abstract start(): void; abstract stop(): void; + refreshNow(): Promise { + return Promise.resolve(); + } + getGeometry(): WindowGeometry | null { return this.currentGeometry; } diff --git a/src/window-trackers/macos-tracker.test.ts b/src/window-trackers/macos-tracker.test.ts index c7dafade..3de7ca31 100644 --- a/src/window-trackers/macos-tracker.test.ts +++ b/src/window-trackers/macos-tracker.test.ts @@ -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 }).refreshNow(); + assert.equal(tracker.isTargetWindowFocused(), false); + + await (tracker as unknown as { refreshNow: () => Promise }).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 = [ diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index 761f1500..45556854 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -196,7 +196,7 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | export class MacOSWindowTracker extends BaseWindowTracker { private pollTimeout: ReturnType | null = null; - private pollInFlight = false; + private pollInFlightPromise: Promise | 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 { + 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 { + 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; } }