mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
fix(overlay): restore mpv focus and pointer state on macOS (#104)
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: character-dictionary
|
|
||||||
|
|
||||||
- Corrected character dictionary setup docs: AniList authentication is not required; the feature uses public GraphQL queries and only needs `subtitleStyle.nameMatchEnabled`.
|
|
||||||
- Added documentation for inline character portraits (`subtitleStyle.nameMatchImagesEnabled`).
|
|
||||||
- Clarified that AniList authentication is only needed for watch-progress sync, not the character dictionary.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed the macOS visible subtitle overlay staying click-through after pause-until-ready releases playback.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Restored mpv focus after closing dedicated modal windows on macOS so subtitles and playback keybinds resume without clicking the player.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed mpv-plugin multi-line copy and mine shortcuts so they open the overlay digit selector instead of dispatching a missing-count action that immediately selects one line.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed subtitle hover auto-pause using stale pause state, which could briefly advance a paused mpv video on Linux/X11 or XWayland.
|
|
||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -1255,6 +1255,12 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
|||||||
signalPluginAutoplayReady: () => {
|
signalPluginAutoplayReady: () => {
|
||||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||||
},
|
},
|
||||||
|
requestOverlayPointerRecovery: () => {
|
||||||
|
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||||
|
},
|
||||||
isSignalTargetReady: (signal) =>
|
isSignalTargetReady: (signal) =>
|
||||||
isTokenizationWarmupReady() &&
|
isTokenizationWarmupReady() &&
|
||||||
isVisibleOverlayAutoplayTargetReady(
|
isVisibleOverlayAutoplayTargetReady(
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export function createMainBootServices<
|
|||||||
params.getSyncOverlayVisibilityForModal()();
|
params.getSyncOverlayVisibilityForModal()();
|
||||||
},
|
},
|
||||||
restoreMainWindowFocus: () => {
|
restoreMainWindowFocus: () => {
|
||||||
|
if (params.platform === 'darwin') return;
|
||||||
const mainWindow = overlayManager.getMainWindow();
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
|
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -417,17 +417,25 @@ test('modal window path makes visible main overlay click-through until modal clo
|
|||||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('modal window path hides visible main overlay until modal closes', () => {
|
test('modal window path restores visible main overlay before modal input deactivates', () => {
|
||||||
const mainWindow = createMockWindow();
|
const mainWindow = createMockWindow();
|
||||||
mainWindow.visible = true;
|
mainWindow.visible = true;
|
||||||
const modalWindow = createMockWindow();
|
const modalWindow = createMockWindow();
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const events: string[] = [];
|
||||||
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
getMainWindow: () => mainWindow as never,
|
getMainWindow: () => mainWindow as never,
|
||||||
getModalWindow: () => modalWindow as never,
|
getModalWindow: () => modalWindow as never,
|
||||||
createModalWindow: () => modalWindow as never,
|
createModalWindow: () => modalWindow as never,
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
setModalWindowBounds: () => {},
|
setModalWindowBounds: () => {},
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
onModalStateChange: (active: boolean): void => {
|
||||||
|
events.push(`state:${active}:visible:${mainWindow.isVisible()}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow(
|
runtime.sendToActiveOverlayWindow(
|
||||||
'youtube:picker-open',
|
'youtube:picker-open',
|
||||||
@@ -444,8 +452,88 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
|||||||
|
|
||||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
|
||||||
assert.equal(mainWindow.getShowCount(), 0);
|
assert.equal(mainWindow.getShowCount(), 1);
|
||||||
assert.equal(mainWindow.isVisible(), false);
|
assert.equal(mainWindow.isVisible(), true);
|
||||||
|
assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal window path runs final close handoff before modal input deactivates', () => {
|
||||||
|
const mainWindow = createMockWindow();
|
||||||
|
mainWindow.visible = true;
|
||||||
|
const modalWindow = createMockWindow();
|
||||||
|
const events: string[] = [];
|
||||||
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
|
getMainWindow: () => mainWindow as never,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => modalWindow as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onFinalModalClosed: (): void => {
|
||||||
|
events.push(`handoff:visible:${mainWindow.isVisible()}`);
|
||||||
|
},
|
||||||
|
onModalStateChange: (active: boolean): void => {
|
||||||
|
events.push(`state:${active}:visible:${mainWindow.isVisible()}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'youtube:picker-open',
|
||||||
|
{ sessionId: 'yt-1' },
|
||||||
|
{
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||||
|
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
'state:true:visible:true',
|
||||||
|
'handoff:visible:true',
|
||||||
|
'state:false:visible:true',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal runtime deactivates modal state when final close handoff throws', () => {
|
||||||
|
const mainWindow = createMockWindow();
|
||||||
|
mainWindow.visible = true;
|
||||||
|
const modalWindow = createMockWindow();
|
||||||
|
const events: string[] = [];
|
||||||
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
|
getMainWindow: () => mainWindow as never,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => modalWindow as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onFinalModalClosed: (): void => {
|
||||||
|
events.push('handoff');
|
||||||
|
throw new Error('handoff failed');
|
||||||
|
},
|
||||||
|
onModalStateChange: (active: boolean): void => {
|
||||||
|
events.push(`state:${active}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'youtube:picker-open',
|
||||||
|
{ sessionId: 'yt-1' },
|
||||||
|
{
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => runtime.handleOverlayModalClosed('youtube-track-picker'));
|
||||||
|
assert.deepEqual(events, ['state:true', 'handoff', 'state:false']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeou
|
|||||||
|
|
||||||
export interface OverlayModalRuntimeOptions {
|
export interface OverlayModalRuntimeOptions {
|
||||||
onModalStateChange?: (isActive: boolean) => void;
|
onModalStateChange?: (isActive: boolean) => void;
|
||||||
|
onFinalModalClosed?: () => void;
|
||||||
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
||||||
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
|
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
|
||||||
}
|
}
|
||||||
@@ -387,9 +388,15 @@ export function createOverlayModalRuntimeService(
|
|||||||
}
|
}
|
||||||
modalWindowPrimedForImmediateShow = false;
|
modalWindowPrimedForImmediateShow = false;
|
||||||
mainWindowMousePassthroughForcedByModal = false;
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
mainWindowHiddenByModal = false;
|
setMainWindowVisibilityForModal(false);
|
||||||
|
try {
|
||||||
|
options.onFinalModalClosed?.();
|
||||||
|
} catch {
|
||||||
|
// Modal state still needs to deactivate if focus handoff fails.
|
||||||
|
} finally {
|
||||||
notifyModalStateChange(false);
|
notifyModalStateChange(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
|
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
|
||||||
|
|||||||
@@ -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 () => {
|
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||||
const commands: Array<Array<string | boolean>> = [];
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
let playbackPaused = true;
|
let playbackPaused = true;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type AutoplayReadyGateDeps = {
|
|||||||
getPlaybackPaused: () => boolean | null;
|
getPlaybackPaused: () => boolean | null;
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
signalPluginAutoplayReady: () => void;
|
signalPluginAutoplayReady: () => void;
|
||||||
|
requestOverlayPointerRecovery?: () => void;
|
||||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
@@ -141,6 +142,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
autoPlayReadySignalMediaPath = mediaPath;
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
deps.signalPluginAutoplayReady();
|
deps.signalPluginAutoplayReady();
|
||||||
|
deps.requestOverlayPointerRecovery?.();
|
||||||
attemptRelease(playbackGeneration, 0);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,3 +33,16 @@ test('overlay preload buffers only latest subtitle state until renderer listener
|
|||||||
);
|
);
|
||||||
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('overlay preload exposes queued pointer recovery requests', () => {
|
||||||
|
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const onOverlayPointerRecoveryRequestEvent =\s*createQueuedIpcListener\(\s*IPC_CHANNELS\.event\.overlayPointerRecoveryRequest,/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/onOverlayPointerRecoveryRequested:\s*onOverlayPointerRecoveryRequestEvent,/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>
|
|||||||
IPC_CHANNELS.event.subtitleSet,
|
IPC_CHANNELS.event.subtitleSet,
|
||||||
(payload) => payload as SubtitleData,
|
(payload) => payload as SubtitleData,
|
||||||
);
|
);
|
||||||
|
const onOverlayPointerRecoveryRequestEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.overlayPointerRecoveryRequest,
|
||||||
|
);
|
||||||
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
|
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
|
||||||
IPC_CHANNELS.event.subtitleVisibility,
|
IPC_CHANNELS.event.subtitleVisibility,
|
||||||
(payload) => payload === true,
|
(payload) => payload === true,
|
||||||
@@ -225,6 +228,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
onSubtitle: (callback: (data: SubtitleData) => void) => {
|
onSubtitle: (callback: (data: SubtitleData) => void) => {
|
||||||
onSubtitleSetEvent(callback);
|
onSubtitleSetEvent(callback);
|
||||||
},
|
},
|
||||||
|
onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent,
|
||||||
|
|
||||||
onVisibility: (callback: (visible: boolean) => void) => {
|
onVisibility: (callback: (visible: boolean) => void) => {
|
||||||
onSubtitleVisibilityEvent(callback);
|
onSubtitleVisibilityEvent(callback);
|
||||||
|
|||||||
@@ -601,6 +601,18 @@ async function init(): Promise<void> {
|
|||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.electronAPI.onOverlayPointerRecoveryRequested(() => {
|
||||||
|
runGuarded('overlay:pointer-recovery', () => {
|
||||||
|
if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAnyModalOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mouseHandlers.restorePointerInteractionState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await keyboardHandlers.setupMpvInputForwarding();
|
await keyboardHandlers.setupMpvInputForwarding();
|
||||||
|
|
||||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export const IPC_CHANNELS = {
|
|||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
|
overlayPointerRecoveryRequest: 'overlay:pointer-recovery-request',
|
||||||
subtitleVisibility: 'mpv:subVisibility',
|
subtitleVisibility: 'mpv:subVisibility',
|
||||||
subtitlePositionSet: 'subtitle-position:set',
|
subtitlePositionSet: 'subtitle-position:set',
|
||||||
subtitleAssSet: 'subtitle-ass:set',
|
subtitleAssSet: 'subtitle-ass:set',
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ export interface SessionNumericSelectionStartPayload {
|
|||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||||
|
onOverlayPointerRecoveryRequested: (callback: () => void) => void;
|
||||||
onVisibility: (callback: (visible: boolean) => void) => void;
|
onVisibility: (callback: (visible: boolean) => void) => void;
|
||||||
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
||||||
getOverlayVisibility: () => Promise<boolean>;
|
getOverlayVisibility: () => Promise<boolean>;
|
||||||
|
|||||||
Reference in New Issue
Block a user