mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 15:13:32 -07:00
fix(overlay): fix macOS overlay interactivity and focus after autoplay (#106)
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user