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

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