fix(overlay): fix macOS overlay interactivity and focus after autoplay (#106)

This commit is contained in:
2026-06-01 01:34:27 -07:00
committed by GitHub
parent 54e90754ef
commit f1e260e996
7 changed files with 221 additions and 115 deletions
+31
View File
@@ -65,6 +65,8 @@ import {
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 { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state';
import { mergeAiConfig } from './ai/config';
@@ -946,6 +948,25 @@ const bootServices = createMainBootServices({
return createOverlayModalRuntimeService(buildHandler(), {
onModalStateChange: (isActive: boolean) =>
overlayModalInputState.handleModalInputStateChange(isActive),
// On macOS, after the last modal closes the post-close visibility sync hides the overlay
// when neither it nor mpv is focused, and keyboard focus is left in limbo (mpv keys like
// shift-to-unpause stop working). Programmatically focusing mpv from our background helper
// is refused by macOS (most visibly in native fullscreen), so instead resign SubMiner's
// active status — exactly what a manual click does — handing focus back to the previously
// active app (mpv). The overlay is already hidden at this point, so app.hide() hides nothing
// visible; once mpv is focused the tracker re-shows the overlay above it.
onFinalModalClosed: () => {
void restoreMacOSMpvFocusAfterModalClose({
platform: process.platform,
focusMpv: async () => {
app.hide();
},
getWindowTracker: () => appState.windowTracker,
updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
warn: (message, details) => logger.warn(message, details),
});
},
});
},
createAppState,
@@ -1259,6 +1280,16 @@ const autoplayReadyGate = createAutoplayReadyGate({
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
return;
}
// Renderer-side recovery alone cannot wake subtitle hover on macOS: the overlay only
// receives mouse-moved events while it is the key window of the frontmost app, and mpv
// is frontmost once playback starts. Activate the overlay window first so the broadcast's
// setIgnoreMouseEvents toggling actually takes effect, mirroring a manual subtitle click.
focusMacOSOverlayWindow({
platform: process.platform,
getOverlayWindow: () => overlayManager.getMainWindow(),
stealAppFocus: () => app.focus({ steal: true }),
warn: (message, details) => logger.warn(message, details),
});
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
},
isSignalTargetReady: (signal) =>
-33
View File
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { focusMacOSMpvProcess } from './macos-mpv-focus';
test('focusMacOSMpvProcess fronts the running mpv process with osascript', async () => {
const calls: Array<{ command: string; args: string[]; timeout?: number }> = [];
await focusMacOSMpvProcess({
execFile: (command, args, options, callback) => {
calls.push({ command, args, timeout: options.timeout });
callback(null);
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0]?.command, '/usr/bin/osascript');
assert.equal(calls[0]?.timeout, 2000);
assert.deepEqual(calls[0]?.args, [
'-e',
'tell application "System Events" to set frontmost of the first process whose name is "mpv" to true',
]);
});
test('focusMacOSMpvProcess rejects when osascript fails', async () => {
await assert.rejects(
focusMacOSMpvProcess({
execFile: (_command, _args, _options, callback) => {
callback(new Error('not allowed'));
},
}),
/not allowed/,
);
});
-35
View File
@@ -1,35 +0,0 @@
import { execFile as nodeExecFile } from 'node:child_process';
const FOCUS_MPV_PROCESS_SCRIPT =
'tell application "System Events" to set frontmost of the first process whose name is "mpv" to true';
type ExecFileForMacOSFocus = (
command: string,
args: string[],
options: { timeout: number },
callback: (error: Error | null) => void,
) => void;
export type MacOSMpvFocusDeps = {
execFile?: ExecFileForMacOSFocus;
};
export async function focusMacOSMpvProcess(deps: MacOSMpvFocusDeps = {}): Promise<void> {
const execFile: ExecFileForMacOSFocus =
deps.execFile ??
((command, args, options, callback) => {
nodeExecFile(command, args, options, (error) => {
callback(error);
});
});
await new Promise<void>((resolve, reject) => {
execFile('/usr/bin/osascript', ['-e', FOCUS_MPV_PROCESS_SCRIPT], { timeout: 2000 }, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { focusMacOSOverlayWindow } from './macos-overlay-window-focus';
function createOverlayWindowStub(
overrides: Partial<{
destroyed: boolean;
visible: boolean;
focused: boolean;
webContentsFocused: boolean;
}> = {},
calls: string[] = [],
) {
return {
isDestroyed: () => overrides.destroyed ?? false,
isVisible: () => overrides.visible ?? true,
isFocused: () => overrides.focused ?? false,
setFocusable: (focusable: boolean) => {
calls.push(`setFocusable:${focusable}`);
},
focus: () => {
calls.push('window.focus');
},
webContents: {
isFocused: () => overrides.webContentsFocused ?? false,
focus: () => {
calls.push('webContents.focus');
},
},
};
}
test('focusMacOSOverlayWindow activates the overlay window on macOS', () => {
const calls: string[] = [];
const overlayWindow = createOverlayWindowStub({}, calls);
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => overlayWindow,
stealAppFocus: () => calls.unshift('app.focus'),
warn: () => {},
});
assert.deepEqual(calls, ['app.focus', 'setFocusable:true', 'window.focus', 'webContents.focus']);
});
test('focusMacOSOverlayWindow skips re-focusing the web contents when already focused', () => {
const calls: string[] = [];
const overlayWindow = createOverlayWindowStub({ webContentsFocused: true }, calls);
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => overlayWindow,
stealAppFocus: () => calls.unshift('app.focus'),
warn: () => {},
});
assert.deepEqual(calls, ['app.focus', 'setFocusable:true', 'window.focus']);
});
test('focusMacOSOverlayWindow no-ops on non-macOS platforms', () => {
const calls: string[] = [];
focusMacOSOverlayWindow({
platform: 'win32',
getOverlayWindow: () => createOverlayWindowStub({}, calls),
stealAppFocus: () => calls.push('app.focus'),
warn: () => {},
});
assert.deepEqual(calls, []);
});
test('focusMacOSOverlayWindow no-ops when the overlay is already focused', () => {
const calls: string[] = [];
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => createOverlayWindowStub({ focused: true }, calls),
stealAppFocus: () => calls.push('app.focus'),
warn: () => {},
});
assert.deepEqual(calls, []);
});
test('focusMacOSOverlayWindow no-ops when the overlay is hidden, destroyed, or missing', () => {
const calls: string[] = [];
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => createOverlayWindowStub({ visible: false }, calls),
stealAppFocus: () => calls.push('app.focus'),
warn: () => {},
});
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => createOverlayWindowStub({ destroyed: true }, calls),
stealAppFocus: () => calls.push('app.focus'),
warn: () => {},
});
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => null,
stealAppFocus: () => calls.push('app.focus'),
warn: () => {},
});
assert.deepEqual(calls, []);
});
test('focusMacOSOverlayWindow still focuses the window when stealing app focus throws', () => {
const calls: string[] = [];
const overlayWindow = createOverlayWindowStub({}, calls);
focusMacOSOverlayWindow({
platform: 'darwin',
getOverlayWindow: () => overlayWindow,
stealAppFocus: () => {
throw new Error('steal failed');
},
warn: (message) => calls.push(`warn:${message}`),
});
assert.deepEqual(calls, [
'warn:Failed to steal app focus for overlay window',
'setFocusable:true',
'window.focus',
'webContents.focus',
]);
});
@@ -0,0 +1,54 @@
type FocusableOverlayWebContents = {
isFocused: () => boolean;
focus: () => void;
};
type FocusableOverlayWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
isFocused: () => boolean;
setFocusable?: (focusable: boolean) => void;
focus: () => void;
webContents: FocusableOverlayWebContents;
};
export type MacOSOverlayWindowFocusDeps = {
platform: NodeJS.Platform;
getOverlayWindow: () => FocusableOverlayWindow | null;
stealAppFocus: () => void;
warn: (message: string, details?: unknown) => void;
};
// macOS only delivers mouse-moved/hover events to the key window of the frontmost application.
// After autoplay warmup completes mpv is the frontmost process, so the transparent overlay window
// receives no pointer events until the user physically clicks a subtitle (which activates the app
// via acceptFirstMouse). Renderer-side pointer recovery can toggle setIgnoreMouseEvents but cannot
// make the window key, so it cannot wake hover on its own. Activating the overlay window from the
// main process reproduces that manual click and keeps subtitles interactive.
// (Modal close takes the opposite path — see restoreMacOSMpvFocusAfterModalClose — because the user
// needs keyboard focus back on mpv, with the overlay floating passively above it.)
export function focusMacOSOverlayWindow(deps: MacOSOverlayWindowFocusDeps): void {
if (deps.platform !== 'darwin') {
return;
}
const overlayWindow = deps.getOverlayWindow();
if (!overlayWindow || overlayWindow.isDestroyed() || !overlayWindow.isVisible()) {
return;
}
if (overlayWindow.isFocused()) {
return;
}
try {
deps.stealAppFocus();
} catch (error) {
deps.warn('Failed to steal app focus for overlay window', error);
}
overlayWindow.setFocusable?.(true);
overlayWindow.focus();
if (!overlayWindow.webContents.isFocused()) {
overlayWindow.webContents.focus();
}
}