mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-13 03:13:32 -07:00
fix(overlay): restore mpv focus and pointer state on macOS (#104)
This commit is contained in:
@@ -95,6 +95,48 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate requests overlay pointer recovery when media readiness is signaled', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let pointerRecoveryRequests = 0;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
requestOverlayPointerRecovery: () => {
|
||||
pointerRecoveryRequests += 1;
|
||||
},
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '字幕その2', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(pointerRecoveryRequests, 1);
|
||||
});
|
||||
|
||||
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let playbackPaused = true;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AutoplayReadyGateDeps = {
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
requestOverlayPointerRecovery?: () => void;
|
||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||
now?: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
@@ -141,6 +142,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
deps.requestOverlayPointerRecovery?.();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { restoreMacOSMpvFocusAfterModalClose } from './macos-modal-focus-handoff';
|
||||
|
||||
test('restoreMacOSMpvFocusAfterModalClose focuses mpv, refreshes tracker, then updates visibility on macOS', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: 'darwin',
|
||||
focusMpv: async () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
getWindowTracker: () => ({
|
||||
refreshNow: async () => {
|
||||
calls.push('refresh');
|
||||
},
|
||||
}),
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'refresh', 'visibility']);
|
||||
});
|
||||
|
||||
test('restoreMacOSMpvFocusAfterModalClose skips non-macOS platforms', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: 'linux',
|
||||
focusMpv: async () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
getWindowTracker: () => null,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('restoreMacOSMpvFocusAfterModalClose still updates visibility when tracker refresh fails', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: 'darwin',
|
||||
focusMpv: async () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
getWindowTracker: () => ({
|
||||
refreshNow: async () => {
|
||||
calls.push('refresh');
|
||||
throw new Error('refresh failed');
|
||||
},
|
||||
}),
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
warn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'focus',
|
||||
'refresh',
|
||||
'warn:Failed to refresh macOS mpv focus after modal close',
|
||||
'visibility',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
type RefreshableWindowTracker = {
|
||||
refreshNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type MacOSModalFocusHandoffDeps = {
|
||||
platform: NodeJS.Platform;
|
||||
focusMpv: () => Promise<void>;
|
||||
getWindowTracker: () => RefreshableWindowTracker | null;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
};
|
||||
|
||||
export async function restoreMacOSMpvFocusAfterModalClose(
|
||||
deps: MacOSModalFocusHandoffDeps,
|
||||
): Promise<void> {
|
||||
if (deps.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.focusMpv();
|
||||
} catch (error) {
|
||||
deps.warn('Failed to focus mpv after macOS modal close', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.getWindowTracker()?.refreshNow();
|
||||
} catch (error) {
|
||||
deps.warn('Failed to refresh macOS mpv focus after modal close', error);
|
||||
}
|
||||
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user