mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 15:13:32 -07:00
fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)
This commit is contained in:
@@ -58,6 +58,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
@@ -66,8 +67,10 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
||||
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
|
||||
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||
activatePlaybackWindowForOverlayInteraction?: IpcDepsRuntimeOptions['activatePlaybackWindowForOverlayInteraction'];
|
||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
|
||||
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
|
||||
@@ -236,6 +239,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
@@ -244,6 +248,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
||||
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
|
||||
getPlaybackPaused: params.getPlaybackPaused,
|
||||
getSubtitlePosition: params.getSubtitlePosition,
|
||||
getSubtitleStyle: params.getSubtitleStyle,
|
||||
@@ -260,6 +265,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction,
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
getMpvClient: params.getMpvClient,
|
||||
runSubsyncManual: params.runSubsyncManual,
|
||||
|
||||
@@ -72,6 +72,35 @@ test('manual visible overlay toggles only release current-media autoplay when hi
|
||||
);
|
||||
});
|
||||
|
||||
test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
const source = readMainSource();
|
||||
const setVisibleBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const setOverlayBlock = source.match(
|
||||
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setVisibleBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.ok(setOverlayBlock);
|
||||
assert.match(
|
||||
setVisibleBlock,
|
||||
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -109,7 +138,7 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay subtitle prime prefers cached annotated payload before raw fallback', () => {
|
||||
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
||||
@@ -122,16 +151,124 @@ test('autoplay subtitle prime prefers cached annotated payload before raw fallba
|
||||
);
|
||||
assert.match(actionBlock, /if \(cachedPayload\) \{/);
|
||||
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const rawPayload = withCurrentSubtitleTiming\(\{ text, tokens: null \}\);/,
|
||||
);
|
||||
assert.doesNotMatch(actionBlock, /withCurrentSubtitleTiming\(\{ text, tokens: null \}\)/);
|
||||
assert.doesNotMatch(actionBlock, /broadcastToOverlayWindows\('subtitle:set', rawPayload\)/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('consumeCachedSubtitle(text)') <
|
||||
actionBlock.indexOf('withCurrentSubtitleTiming({ text, tokens: null })'),
|
||||
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(gateBlock);
|
||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
|
||||
const source = readMainSource();
|
||||
const openedBlock = source.match(
|
||||
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const closedBlock = source.match(
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
||||
?.body;
|
||||
|
||||
assert.ok(openedBlock);
|
||||
assert.ok(closedBlock);
|
||||
assert.ok(depsBlock);
|
||||
assert.match(openedBlock, /if \(modal === 'subtitle-sidebar'/);
|
||||
assert.match(openedBlock, /subtitleSidebarRequestedOpen = true;/);
|
||||
assert.match(closedBlock, /if \(modal === 'subtitle-sidebar'/);
|
||||
assert.match(closedBlock, /subtitleSidebarRequestedOpen = false;/);
|
||||
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
|
||||
});
|
||||
|
||||
test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
|
||||
const source = readMainSource();
|
||||
const warmReleaseBlock = source.match(
|
||||
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const currentPayloadBlock = source.match(
|
||||
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(warmReleaseBlock);
|
||||
assert.match(
|
||||
warmReleaseBlock,
|
||||
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(warmReleaseBlock, /__warm__/);
|
||||
|
||||
assert.ok(currentPayloadBlock);
|
||||
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
|
||||
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
||||
});
|
||||
|
||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const resetBlock = source.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.ok(resetBlock);
|
||||
assert.match(actionBlock, /resetVisibleOverlayInputState\(\);/);
|
||||
assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('resetVisibleOverlayInputState();') <
|
||||
actionBlock.indexOf('createMainWindow();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/);
|
||||
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
|
||||
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
|
||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||
});
|
||||
|
||||
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -169,6 +306,60 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
|
||||
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
|
||||
});
|
||||
|
||||
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||
const source = readMainSource();
|
||||
const resetBlock = source.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resetBlock);
|
||||
assert.ok(setBlock);
|
||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||
const source = readMainSource();
|
||||
const afterBoundsBlock = source.match(
|
||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(afterBoundsBlock);
|
||||
assert.match(afterBoundsBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.ok(
|
||||
afterBoundsBlock.indexOf('restoreLinuxOverlayWindowShape(mainWindow);') <
|
||||
afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getModalActive: () => boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getNonNativeInputRegionActive?: () => boolean;
|
||||
getSuspendVisibleOverlay?: () => boolean;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
@@ -30,6 +31,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
hideNonNativeOverlayWhenTargetUnfocused?: () => boolean;
|
||||
}
|
||||
|
||||
export interface OverlayVisibilityRuntimeService {
|
||||
@@ -53,6 +55,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough,
|
||||
nonNativeInputRegionActive: deps.getNonNativeInputRegionActive?.() ?? false,
|
||||
suspendVisibleOverlay,
|
||||
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||
mainWindow,
|
||||
@@ -86,6 +89,8 @@ export function createOverlayVisibilityRuntimeService(
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
},
|
||||
hideNonNativeOverlayWhenTargetUnfocused:
|
||||
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -311,3 +311,58 @@ test('autoplay ready gate drops deferred readiness after media changes before fl
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('autoplay ready gate passes the pending subtitle signal to the readiness predicate', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReadyText: string | null = null;
|
||||
let observedText: string | null = null;
|
||||
let observedRequestedAtMs: number | null = null;
|
||||
let now = 1_000;
|
||||
|
||||
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']);
|
||||
},
|
||||
isSignalTargetReady: ((signal: { payload: { text: string }; requestedAtMs: number }) => {
|
||||
observedText = signal.payload.text;
|
||||
observedRequestedAtMs = signal.requestedAtMs;
|
||||
return targetReadyText === signal.payload.text;
|
||||
}) as never,
|
||||
now: () => now,
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(observedText, '字幕');
|
||||
assert.equal(observedRequestedAtMs, 1_000);
|
||||
assert.deepEqual(commands, []);
|
||||
|
||||
now = 2_000;
|
||||
targetReadyText = '字幕';
|
||||
gate.flushPendingAutoplayReadySignal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(observedRequestedAtMs, 1_000);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,15 @@ type MpvClientLike = {
|
||||
send: (payload: { command: Array<string | boolean> }) => void;
|
||||
};
|
||||
|
||||
type AutoplayReadyOptions = { forceWhilePaused?: boolean };
|
||||
|
||||
export type AutoplayReadySignal = {
|
||||
mediaPath: string;
|
||||
payload: SubtitleData;
|
||||
requestedAtMs: number;
|
||||
options?: AutoplayReadyOptions;
|
||||
};
|
||||
|
||||
export type AutoplayReadyGateDeps = {
|
||||
isAppOwnedFlowInFlight: () => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
@@ -14,7 +23,8 @@ export type AutoplayReadyGateDeps = {
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
isSignalTargetReady?: () => boolean;
|
||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||
now?: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logDebug: (message: string) => void;
|
||||
};
|
||||
@@ -22,11 +32,8 @@ export type AutoplayReadyGateDeps = {
|
||||
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
let pendingAutoplayReadySignal: {
|
||||
mediaPath: string;
|
||||
payload: SubtitleData;
|
||||
options?: { forceWhilePaused?: boolean };
|
||||
} | null = null;
|
||||
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
@@ -34,7 +41,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
|
||||
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
|
||||
deps.isSignalTargetReady?.(signal) ?? true;
|
||||
|
||||
const getSignalMediaPath = (): string =>
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
@@ -45,23 +53,23 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
): void => {
|
||||
if (deps.isAppOwnedFlowInFlight()) {
|
||||
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||
return;
|
||||
}
|
||||
if (!payload.text.trim()) {
|
||||
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
if (
|
||||
pendingAutoplayReadySignal &&
|
||||
pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
|
||||
pendingAutoplayReadySignal.payload.text === signal.payload.text &&
|
||||
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingAutoplayReadySignal = signal;
|
||||
};
|
||||
|
||||
const mediaPath = getSignalMediaPath();
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
const mediaPath = signal.mediaPath;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
forceWhilePaused: signal.options?.forceWhilePaused === true,
|
||||
retryDelayMs: releaseRetryDelayMs,
|
||||
});
|
||||
let releaseUnpauseSent = false;
|
||||
@@ -129,18 +137,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
})();
|
||||
};
|
||||
|
||||
if (duplicateMediaSignal) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady()) {
|
||||
pendingAutoplayReadySignal = { mediaPath, payload, options };
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
@@ -148,20 +144,56 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
const maybeReleaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
if (autoPlayReadySignalMediaPath === signal.mediaPath) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady(signal)) {
|
||||
setPendingAutoplayReadySignal(signal);
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAutoplayReadySignal(signal);
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: AutoplayReadyOptions,
|
||||
): void => {
|
||||
if (deps.isAppOwnedFlowInFlight()) {
|
||||
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||
return;
|
||||
}
|
||||
if (!payload.text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
maybeReleaseAutoplayReadySignal({
|
||||
mediaPath: getSignalMediaPath(),
|
||||
payload,
|
||||
requestedAtMs: now(),
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
const flushPendingAutoplayReadySignal = (): void => {
|
||||
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
|
||||
if (!pendingAutoplayReadySignal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSignal = pendingAutoplayReadySignal;
|
||||
pendingAutoplayReadySignal = null;
|
||||
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
|
||||
maybeReleaseAutoplayReadySignal(pendingSignal);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
|
||||
import {
|
||||
primeVisibleOverlaySubtitleFromMpv,
|
||||
resolveCurrentSubtitleForRenderer,
|
||||
} from './current-subtitle-snapshot';
|
||||
|
||||
function withTiming(payload: SubtitleData): SubtitleData {
|
||||
return {
|
||||
@@ -58,3 +61,95 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
calls.push(`request:${name}`);
|
||||
return '国内外から';
|
||||
},
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => null,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
|
||||
const calls: string[] = [];
|
||||
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => '字幕',
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => cachedPayload,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}:${payload.tokens?.length ?? 0}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['set:字幕', 'emit:字幕:1', 'refresh:字幕']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime clears stale subtitle when mpv has no current text', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => '',
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => ({ text: 'old', tokens: null }),
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}:${payload.tokens}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['set:', 'change:', 'emit::null']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime refreshes secondary subtitle when available', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
calls.push(`request:${name}`);
|
||||
return name === 'secondary-sub-text' ? 'from abroad' : '国内外から';
|
||||
},
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => null,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||
setCurrentSecondarySubText: (text) => calls.push(`set-secondary:${text}`),
|
||||
emitSecondarySubtitle: (text) => calls.push(`emit-secondary:${text}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'request:sub-text',
|
||||
'set:国内外から',
|
||||
'refresh:国内外から',
|
||||
'request:secondary-sub-text',
|
||||
'set-secondary:from abroad',
|
||||
'emit-secondary:from abroad',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
type CurrentSubtitleMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
@@ -27,3 +32,81 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
getMpvClient: () => CurrentSubtitleMpvClient | null;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getCurrentSubtitleData: () => SubtitleData | null;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
emitSecondarySubtitle?: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let subTextRaw: unknown;
|
||||
try {
|
||||
subTextRaw = await client.requestProperty('sub-text');
|
||||
} catch (error) {
|
||||
deps.logDebug?.(
|
||||
`[visible-overlay-subtitle-prime] failed to read sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
deps.setCurrentSubText(text);
|
||||
|
||||
const primeSecondarySubtitle = async (): Promise<void> => {
|
||||
if (!deps.setCurrentSecondarySubText && !deps.emitSecondarySubtitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const secondarySubTextRaw = await client.requestProperty('secondary-sub-text');
|
||||
const secondaryText = typeof secondarySubTextRaw === 'string' ? secondarySubTextRaw : '';
|
||||
deps.setCurrentSecondarySubText?.(secondaryText);
|
||||
deps.emitSecondarySubtitle?.(secondaryText);
|
||||
} catch (error) {
|
||||
deps.logDebug?.(
|
||||
`[visible-overlay-subtitle-prime] failed to read secondary-sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!text.trim()) {
|
||||
deps.onSubtitleChange(text);
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPayload = deps.getCurrentSubtitleData();
|
||||
if (currentPayload?.text === text) {
|
||||
deps.emitSubtitle(currentPayload);
|
||||
deps.refreshCurrentSubtitle(text);
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPayload = deps.consumeCachedSubtitle(text);
|
||||
if (cachedPayload) {
|
||||
deps.onSubtitleChange(text);
|
||||
deps.emitSubtitle(cachedPayload);
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.refreshCurrentSubtitle(text);
|
||||
await primeSecondarySubtitle();
|
||||
}
|
||||
|
||||
@@ -16,13 +16,19 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => false,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -30,6 +36,8 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
@@ -39,8 +47,11 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
}
|
||||
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('sync-overlay-mode:true'));
|
||||
assert.ok(!calls.includes('fullscreen:true'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
@@ -50,7 +61,46 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh update schedules a fresh burst when fullscreen exits', async () => {
|
||||
test('linux mpv fullscreen overlay refresh remembers mode even when overlay is hidden', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('sync-overlay-mode:true') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
assert.ok(calls.includes('sync-overlay-mode:true'));
|
||||
assert.ok(!calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(!calls.includes('ensureOverlayWindowLevel'));
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh updates mode without hide/show when fullscreen exits', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
@@ -65,8 +115,14 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => true,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -74,6 +130,8 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
};
|
||||
|
||||
@@ -84,9 +142,125 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
|
||||
assert.equal(typeof nextCancel, 'function');
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
assert.ok(calls.includes('sync-overlay-mode:false'));
|
||||
assert.ok(!calls.includes('fullscreen:false'));
|
||||
assert.equal(calls.includes('hide'), false);
|
||||
assert.equal(calls.includes('showInactive'), false);
|
||||
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
|
||||
assert.equal(calls.includes('ensureOverlayWindowLevel'), false);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh restores click-through after restacking', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => false,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('mouse-ignore:true:forward') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const showIndex = calls.indexOf('showInactive');
|
||||
const passthroughIndex = calls.indexOf('mouse-ignore:true:forward');
|
||||
const levelIndex = calls.indexOf('ensureOverlayWindowLevel');
|
||||
const syncIndex = calls.indexOf('sync-overlay-mode:true');
|
||||
|
||||
assert.ok(syncIndex >= 0);
|
||||
assert.ok(showIndex >= 0);
|
||||
assert.ok(syncIndex < showIndex);
|
||||
assert.ok(passthroughIndex > showIndex);
|
||||
assert.ok(levelIndex > passthroughIndex);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh preserves active subtitle interaction after restacking', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => false,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
getOverlayInteractionActive: () => true,
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('mouse-ignore:false:plain') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const showIndex = calls.indexOf('showInactive');
|
||||
const interactiveIndex = calls.indexOf('mouse-ignore:false:plain');
|
||||
|
||||
assert.ok(showIndex >= 0);
|
||||
assert.ok(interactiveIndex > showIndex);
|
||||
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
|
||||
@@ -2,6 +2,7 @@ type LinuxMpvFullscreenOverlayWindow = {
|
||||
hide: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
showInactive: () => void;
|
||||
};
|
||||
|
||||
@@ -13,6 +14,8 @@ export type LinuxMpvFullscreenOverlayRefreshDeps = {
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
syncVisibleOverlayMpvFullscreenMode?: (fullscreen: boolean) => void;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
|
||||
};
|
||||
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
|
||||
@@ -28,13 +31,21 @@ function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
|
||||
}
|
||||
|
||||
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
fullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): void {
|
||||
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.syncVisibleOverlayMpvFullscreenMode?.(fullscreen);
|
||||
if (!deps.overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
if (!fullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = deps.overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
@@ -43,10 +54,16 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
if (deps.getOverlayInteractionActive?.() === true) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
deps.ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
|
||||
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
isFullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
|
||||
if (process.platform !== 'linux') {
|
||||
@@ -59,7 +76,7 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(isFullscreen, deps);
|
||||
}, delayMs);
|
||||
refreshTimeout.unref?.();
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
|
||||
@@ -68,13 +85,13 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
}
|
||||
|
||||
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
_isFullscreen: boolean,
|
||||
isFullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
|
||||
cancelCurrentBurst?.();
|
||||
|
||||
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
|
||||
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(isFullscreen, deps);
|
||||
}
|
||||
|
||||
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyLinuxOverlayInputShape,
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough,
|
||||
type LinuxOverlayPointerInteractionDeps,
|
||||
isCursorOverSubtitle,
|
||||
type ForegroundSuppressionGraceState,
|
||||
mapOverlayMeasurementForPointerInteraction,
|
||||
resolveDesiredOverlayInteractive,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldSuppressPointerInteractionForForegroundWindow,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './linux-overlay-pointer-interaction';
|
||||
|
||||
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
|
||||
const MEASUREMENT = {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 800, y: 900, width: 320, height: 80 },
|
||||
};
|
||||
|
||||
test('isCursorOverSubtitle hit-tests the subtitle rect in screen coords (1:1 scale)', () => {
|
||||
// Subtitle rect maps to screen [900..1220] x [1000..1080] (+100 window origin).
|
||||
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT), true);
|
||||
assert.equal(isCursorOverSubtitle({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT), false);
|
||||
assert.equal(isCursorOverSubtitle({ x: 1000, y: 500 }, BOUNDS, MEASUREMENT), false);
|
||||
});
|
||||
|
||||
test('isCursorOverSubtitle scales viewport px to window px', () => {
|
||||
// Window is 2x the reported viewport → rect doubles.
|
||||
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
|
||||
// contentRect.x*2=1600 +100 origin → left ~1700; a point at 1700,1900 is inside.
|
||||
assert.equal(isCursorOverSubtitle({ x: 1700, y: 1900 }, scaled, MEASUREMENT), true);
|
||||
});
|
||||
|
||||
test('isCursorOverSubtitle returns false without a content rect', () => {
|
||||
assert.equal(
|
||||
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
|
||||
viewport: MEASUREMENT.viewport,
|
||||
contentRect: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, null), false);
|
||||
});
|
||||
|
||||
test('isCursorOverSubtitle falls back to content rect when interactive rects are empty', () => {
|
||||
assert.equal(
|
||||
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
|
||||
...MEASUREMENT,
|
||||
interactiveRects: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
function makeDeps(overrides: Partial<LinuxOverlayPointerInteractionDeps>): {
|
||||
deps: LinuxOverlayPointerInteractionDeps;
|
||||
state: { active: boolean };
|
||||
} {
|
||||
const state = { active: false };
|
||||
const deps: LinuxOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
getRendererInteractiveHint: () => false,
|
||||
shouldSuspend: () => false,
|
||||
getInteractionActive: () => state.active,
|
||||
setInteractionActive: (active) => {
|
||||
state.active = active;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { deps, state };
|
||||
}
|
||||
|
||||
test('resolveDesiredOverlayInteractive: interactive over subtitle, passthrough off it', () => {
|
||||
assert.equal(resolveDesiredOverlayInteractive(makeDeps({}).deps), true);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredOverlayInteractive: renderer hint keeps it interactive off the rect', () => {
|
||||
const { deps } = makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
|
||||
getRendererInteractiveHint: () => true,
|
||||
});
|
||||
assert.equal(resolveDesiredOverlayInteractive(deps), true);
|
||||
});
|
||||
|
||||
test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without blocking between them', () => {
|
||||
const measurement = {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
} as unknown as ReturnType<LinuxOverlayPointerInteractionDeps['getSubtitleMeasurement']>;
|
||||
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 900, y: 300 }),
|
||||
getSubtitleMeasurement: () => measurement,
|
||||
}).deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 900, y: 1060 }),
|
||||
getSubtitleMeasurement: () => measurement,
|
||||
}).deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 900, y: 180 }),
|
||||
getSubtitleMeasurement: () => measurement,
|
||||
}).deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
|
||||
const mapped = mapOverlayMeasurementForPointerInteraction({
|
||||
layer: 'visible',
|
||||
measuredAtMs: 1,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(mapped, {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover when another app is foreground', () => {
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: true,
|
||||
isOverlayWindowFocused: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveForegroundSuppressionWithGrace ignores a transient startup focus blip', () => {
|
||||
// Regression: right after playback starts the overlay can briefly become the X11 active
|
||||
// window, so the tracker reports mpv unfocused. Suppressing immediately leaves subtitles
|
||||
// inert for ~1s. The grace must hold interaction available until the loss is *stable*.
|
||||
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
|
||||
const base = {
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: false,
|
||||
graceMs: 500,
|
||||
state,
|
||||
};
|
||||
|
||||
// Blip starts: not yet suppressed.
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
|
||||
// Still within grace.
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_400 }), false);
|
||||
// mpv regains focus before the grace elapses → reset, never suppressed.
|
||||
assert.equal(
|
||||
resolveForegroundSuppressionWithGrace({ ...base, isMpvWindowFocused: true, nowMs: 1_450 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(state.lossSinceMs, null);
|
||||
});
|
||||
|
||||
test('resolveForegroundSuppressionWithGrace suppresses once foreground loss is stable', () => {
|
||||
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
|
||||
const base = {
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: false,
|
||||
graceMs: 500,
|
||||
state,
|
||||
};
|
||||
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
|
||||
// A real app stays foreground past the grace → suppress.
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_500 }), true);
|
||||
});
|
||||
|
||||
test('resolveForegroundSuppressionWithGrace defers to a separate window immediately', () => {
|
||||
const state: ForegroundSuppressionGraceState = { lossSinceMs: 1_000 };
|
||||
assert.equal(
|
||||
resolveForegroundSuppressionWithGrace({
|
||||
hasForegroundSeparateWindow: true,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: true,
|
||||
isOverlayWindowFocused: false,
|
||||
nowMs: 2_000,
|
||||
graceMs: 500,
|
||||
state,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(state.lossSinceMs, null);
|
||||
});
|
||||
|
||||
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover for separate app windows', () => {
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: true,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: true,
|
||||
isOverlayWindowFocused: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredOverlayInteractive: false when overlay hidden, null when suspended/no window', () => {
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(makeDeps({ getVisibleOverlayVisible: () => false }).deps),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('tick only writes interaction state on change', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps, state } = makeDeps({
|
||||
setInteractionActive: (active) => {
|
||||
calls.push(active);
|
||||
state.active = active;
|
||||
},
|
||||
});
|
||||
tickLinuxOverlayPointerInteraction(deps); // off→on
|
||||
tickLinuxOverlayPointerInteraction(deps); // no change
|
||||
assert.deepEqual(calls, [true]);
|
||||
});
|
||||
|
||||
test('tick does not flip state when suspended (returns null)', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getInteractionActive: () => true,
|
||||
shouldSuspend: () => true,
|
||||
setInteractionActive: (active) => calls.push(active),
|
||||
});
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('tick clears active hover while a separate SubMiner window suppresses overlay interaction', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps, state } = makeDeps({
|
||||
getInteractionActive: () => true,
|
||||
shouldSuppressInteraction: () => true,
|
||||
setInteractionActive: (active) => {
|
||||
calls.push(active);
|
||||
state.active = active;
|
||||
},
|
||||
});
|
||||
|
||||
state.active = true;
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, [false]);
|
||||
});
|
||||
|
||||
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getInteractionActive: () => false,
|
||||
shouldUseInputShape: () => true,
|
||||
setInteractionActive: (active) => calls.push(active),
|
||||
});
|
||||
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayInputShape shapes measured subtitle rects and enables mouse input', () => {
|
||||
const calls: string[] = [];
|
||||
const window = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => ({ ...BOUNDS, width: 3840, height: 2160 }),
|
||||
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
||||
calls.push(`shape:${JSON.stringify(rects)}`);
|
||||
},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => window,
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
getRendererInteractiveHint: () => false,
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => false,
|
||||
}),
|
||||
{ handled: true, active: true },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'shape:[{"x":1594,"y":1794,"width":652,"height":172}]',
|
||||
'ignore:false:plain',
|
||||
]);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayInputShape uses the full window while renderer reports off-rect interaction', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.deepEqual(
|
||||
applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
||||
calls.push(`shape:${JSON.stringify(rects)}`);
|
||||
},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
}),
|
||||
getSubtitleMeasurement: () => null,
|
||||
getRendererInteractiveHint: () => true,
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
{ handled: true, active: true },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'shape:[{"x":0,"y":0,"width":1920,"height":1080}]',
|
||||
'ignore:false:plain',
|
||||
]);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayInputShape falls back when setShape is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.deepEqual(
|
||||
applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('ignore');
|
||||
},
|
||||
}),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
getRendererInteractiveHint: () => false,
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
{ handled: false, active: false },
|
||||
);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayPointerInteractionMousePassthrough toggles mouse input without full visibility refresh', () => {
|
||||
const calls: string[] = [];
|
||||
const window = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||
active: true,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => window,
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => false,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('full-refresh');
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, ['ignore:false:plain']);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayPointerInteractionMousePassthrough falls back when pointer interaction is suppressed', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.equal(
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||
active: false,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('mouse-ignore');
|
||||
},
|
||||
}),
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('full-refresh');
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(calls, ['full-refresh']);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
Linux overlay pointer-interaction loop.
|
||||
|
||||
Electron cannot forward mouse-move events through a click-through window on Linux/X11
|
||||
(the `forward` option of setIgnoreMouseEvents is unsupported there — electron/electron#16777).
|
||||
The overlay's hover/lookup interaction relied on those forwarded events, so under XWayland
|
||||
the click-through overlay never sees the cursor and stays inert.
|
||||
|
||||
This restores the Windows/macOS behavior with either a Linux input shape (preferred) or a
|
||||
main-process cursor poll fallback. Input shapes keep only reported subtitle/sidebar rects
|
||||
mouse-active so entering a subtitle does not have to flip BrowserWindow mouse-ignore state.
|
||||
The cursor poll remains for runtimes where BrowserWindow.setShape is unavailable.
|
||||
*/
|
||||
|
||||
import type { OverlayContentMeasurement } from '../../types';
|
||||
|
||||
export type PointerPoint = { x: number; y: number };
|
||||
export type PointerRect = { x: number; y: number; width: number; height: number };
|
||||
export type PointerViewport = { width: number; height: number };
|
||||
|
||||
export type OverlayContentMeasurementLike = {
|
||||
viewport: PointerViewport;
|
||||
contentRect: PointerRect | null;
|
||||
interactiveRects?: PointerRect[] | null;
|
||||
} | null;
|
||||
|
||||
type PointerInteractionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
getBounds: () => PointerRect;
|
||||
};
|
||||
|
||||
type PointerInteractionMousePassthroughWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
};
|
||||
|
||||
type PointerInteractionShapeWindow = PointerInteractionMousePassthroughWindow & {
|
||||
getBounds: () => PointerRect;
|
||||
setShape?: (rects: PointerRect[]) => void;
|
||||
};
|
||||
|
||||
export type LinuxOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionWindow | null;
|
||||
getCursorScreenPoint: () => PointerPoint;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
|
||||
getRendererInteractiveHint: () => boolean;
|
||||
/** True when a modal/stats overlay owns input — leave interaction state to that logic. */
|
||||
shouldSuspend: () => boolean;
|
||||
/** True when a separate app window should stay above the overlay. */
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
shouldUseInputShape?: () => boolean;
|
||||
getInteractionActive: () => boolean;
|
||||
setInteractionActive: (active: boolean) => void;
|
||||
};
|
||||
|
||||
export const LINUX_OVERLAY_POINTER_POLL_INTERVAL_MS = 60;
|
||||
// Padding (in window px) so the cursor doesn't have to land pixel-perfectly on the text.
|
||||
const SUBTITLE_HIT_PADDING_PX = 6;
|
||||
|
||||
let pointerInteractionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function mapOverlayMeasurementForPointerInteraction(
|
||||
measurement: OverlayContentMeasurement | null,
|
||||
): OverlayContentMeasurementLike {
|
||||
if (!measurement) return null;
|
||||
return {
|
||||
viewport: measurement.viewport,
|
||||
contentRect: measurement.contentRect,
|
||||
...(measurement.interactiveRects ? { interactiveRects: measurement.interactiveRects } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldSuppressPointerInteractionForForegroundWindow(options: {
|
||||
hasForegroundSeparateWindow: boolean;
|
||||
isTrackingMpvWindow: boolean;
|
||||
isMpvWindowFocused: boolean;
|
||||
isOverlayWindowFocused: boolean;
|
||||
}): boolean {
|
||||
if (options.hasForegroundSeparateWindow) return true;
|
||||
if (!options.isTrackingMpvWindow) return false;
|
||||
return !options.isMpvWindowFocused && !options.isOverlayWindowFocused;
|
||||
}
|
||||
|
||||
/** Mutable timer state for {@link resolveForegroundSuppressionWithGrace}. */
|
||||
export type ForegroundSuppressionGraceState = { lossSinceMs: number | null };
|
||||
|
||||
/**
|
||||
* Suppress subtitle pointer interaction for a foreground window, but only once the foreground
|
||||
* loss has been *stable* for `graceMs`. A separate SubMiner window defers immediately; a plain
|
||||
* focus blip (e.g. the overlay briefly becoming the X11 active window at playback start) is
|
||||
* ignored so subtitles don't go inert for a poll cycle while focus settles back onto mpv.
|
||||
*/
|
||||
export function resolveForegroundSuppressionWithGrace(options: {
|
||||
hasForegroundSeparateWindow: boolean;
|
||||
isTrackingMpvWindow: boolean;
|
||||
isMpvWindowFocused: boolean;
|
||||
isOverlayWindowFocused: boolean;
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
state: ForegroundSuppressionGraceState;
|
||||
}): boolean {
|
||||
if (options.hasForegroundSeparateWindow) {
|
||||
options.state.lossSinceMs = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawSuppress = shouldSuppressPointerInteractionForForegroundWindow(options);
|
||||
if (!rawSuppress) {
|
||||
options.state.lossSinceMs = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.state.lossSinceMs === null) {
|
||||
options.state.lossSinceMs = options.nowMs;
|
||||
}
|
||||
return options.nowMs - options.state.lossSinceMs >= options.graceMs;
|
||||
}
|
||||
|
||||
function isCursorOverRect(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
viewport: PointerViewport,
|
||||
rect: PointerRect,
|
||||
): boolean {
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
|
||||
|
||||
const scaleX = bounds.width / viewport.width;
|
||||
const scaleY = bounds.height / viewport.height;
|
||||
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
|
||||
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
|
||||
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
|
||||
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
|
||||
|
||||
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
|
||||
}
|
||||
|
||||
function measuredRectsForInput(measurement: OverlayContentMeasurementLike): PointerRect[] {
|
||||
if (!measurement) return [];
|
||||
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
|
||||
? measurement.interactiveRects
|
||||
: measurement.contentRect
|
||||
? [measurement.contentRect]
|
||||
: [];
|
||||
}
|
||||
|
||||
function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
|
||||
const left = Math.max(0, Math.floor(rect.x));
|
||||
const top = Math.max(0, Math.floor(rect.y));
|
||||
const right = Math.min(Math.ceil(bounds.width), Math.ceil(rect.x + rect.width));
|
||||
const bottom = Math.min(Math.ceil(bounds.height), Math.ceil(rect.y + rect.height));
|
||||
if (right <= left || bottom <= top) return null;
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMeasuredRectToWindowShape(
|
||||
bounds: PointerRect,
|
||||
viewport: PointerViewport,
|
||||
rect: PointerRect,
|
||||
): PointerRect | null {
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return null;
|
||||
if (!(viewport.width > 0) || !(viewport.height > 0)) return null;
|
||||
|
||||
const scaleX = bounds.width / viewport.width;
|
||||
const scaleY = bounds.height / viewport.height;
|
||||
return clampRectToWindow(
|
||||
{
|
||||
x: rect.x * scaleX - SUBTITLE_HIT_PADDING_PX,
|
||||
y: rect.y * scaleY - SUBTITLE_HIT_PADDING_PX,
|
||||
width: rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2,
|
||||
height: rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2,
|
||||
},
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInputShapeRects(options: {
|
||||
bounds: PointerRect;
|
||||
measurement: OverlayContentMeasurementLike;
|
||||
rendererInteractiveHint: boolean;
|
||||
}): PointerRect[] {
|
||||
const { bounds } = options;
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return [];
|
||||
|
||||
if (options.rendererInteractiveHint) {
|
||||
return [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(bounds.width),
|
||||
height: Math.ceil(bounds.height),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const measurement = options.measurement;
|
||||
if (!measurement) return [];
|
||||
return measuredRectsForInput(measurement)
|
||||
.map((rect) => mapMeasuredRectToWindowShape(bounds, measurement.viewport, rect))
|
||||
.filter((rect): rect is PointerRect => rect !== null);
|
||||
}
|
||||
|
||||
/** Hit-test the global cursor against subtitle bar rects, mapping viewport px → screen px. */
|
||||
export function isCursorOverSubtitle(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
measurement: OverlayContentMeasurementLike,
|
||||
): boolean {
|
||||
if (!measurement) return false;
|
||||
const { viewport } = measurement;
|
||||
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
|
||||
|
||||
const rects = measuredRectsForInput(measurement);
|
||||
|
||||
return rects.some((rect) => isCursorOverRect(cursor, bounds, viewport, rect));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the desired interactive state, or null when the loop should not touch it
|
||||
* (overlay hidden/destroyed or another surface owns input).
|
||||
*/
|
||||
export function resolveDesiredOverlayInteractive(
|
||||
deps: LinuxOverlayPointerInteractionDeps,
|
||||
): boolean | null {
|
||||
if (!deps.getVisibleOverlayVisible()) return false;
|
||||
if (deps.shouldSuspend()) return null;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deps.shouldSuppressInteraction?.()) return false;
|
||||
if (deps.getRendererInteractiveHint()) return true;
|
||||
return isCursorOverSubtitle(
|
||||
deps.getCursorScreenPoint(),
|
||||
mainWindow.getBounds(),
|
||||
deps.getSubtitleMeasurement(),
|
||||
);
|
||||
}
|
||||
|
||||
export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInteractionDeps): void {
|
||||
if (deps.shouldUseInputShape?.()) return;
|
||||
const desired = resolveDesiredOverlayInteractive(deps);
|
||||
if (desired === null) return;
|
||||
if (deps.getInteractionActive() === desired) return;
|
||||
deps.setInteractionActive(desired);
|
||||
}
|
||||
|
||||
export function applyLinuxOverlayInputShape(deps: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionShapeWindow | null;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
|
||||
getRendererInteractiveHint: () => boolean;
|
||||
shouldSuspend: () => boolean;
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
}): { handled: boolean; active: boolean } {
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || typeof mainWindow.setShape !== 'function') {
|
||||
return { handled: false, active: false };
|
||||
}
|
||||
|
||||
if (
|
||||
!deps.getVisibleOverlayVisible() ||
|
||||
deps.shouldSuspend() ||
|
||||
mainWindow.isDestroyed() ||
|
||||
deps.shouldSuppressInteraction?.()
|
||||
) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
mainWindow.setShape([]);
|
||||
return { handled: true, active: false };
|
||||
}
|
||||
|
||||
const rects = resolveInputShapeRects({
|
||||
bounds: mainWindow.getBounds(),
|
||||
measurement: deps.getSubtitleMeasurement(),
|
||||
rendererInteractiveHint: deps.getRendererInteractiveHint(),
|
||||
});
|
||||
|
||||
if (rects.length === 0) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
mainWindow.setShape([]);
|
||||
return { handled: true, active: false };
|
||||
}
|
||||
|
||||
mainWindow.setShape(rects);
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
return { handled: true, active: true };
|
||||
}
|
||||
|
||||
export function applyLinuxOverlayPointerInteractionMousePassthrough(deps: {
|
||||
active: boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionMousePassthroughWindow | null;
|
||||
shouldSuspend: () => boolean;
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
}): boolean {
|
||||
if (!deps.getVisibleOverlayVisible() || deps.shouldSuspend()) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deps.shouldSuppressInteraction?.()) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deps.active) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureLinuxOverlayPointerInteractionLoop(
|
||||
deps: LinuxOverlayPointerInteractionDeps,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (pointerInteractionInterval !== null) return;
|
||||
if (platform !== 'linux') return;
|
||||
|
||||
pointerInteractionInterval = setInterval(() => {
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
}, LINUX_OVERLAY_POINTER_POLL_INTERVAL_MS);
|
||||
pointerInteractionInterval.unref?.();
|
||||
}
|
||||
|
||||
export function stopLinuxOverlayPointerInteractionLoop(): void {
|
||||
if (pointerInteractionInterval === null) return;
|
||||
clearInterval(pointerInteractionInterval);
|
||||
pointerInteractionInterval = null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildFullWindowShapeRect,
|
||||
restoreLinuxOverlayWindowShape,
|
||||
} from './linux-overlay-window-shape';
|
||||
|
||||
test('buildFullWindowShapeRect maps current bounds to a full-window shape', () => {
|
||||
assert.deepEqual(buildFullWindowShapeRect({ x: 100, y: 50, width: 1919.6, height: 1080.4 }), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildFullWindowShapeRect rejects invalid dimensions', () => {
|
||||
assert.equal(buildFullWindowShapeRect({ x: 0, y: 0, width: 0, height: 1080 }), null);
|
||||
assert.equal(buildFullWindowShapeRect({ x: 0, y: 0, width: 1920, height: Number.NaN }), null);
|
||||
});
|
||||
|
||||
test('restoreLinuxOverlayWindowShape restores a full drawable shape', () => {
|
||||
const calls: unknown[] = [];
|
||||
|
||||
assert.equal(
|
||||
restoreLinuxOverlayWindowShape({
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 760, y: 152, width: 1920, height: 1080 }),
|
||||
setShape: (rects) => calls.push(rects),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [[{ x: 0, y: 0, width: 1920, height: 1080 }]]);
|
||||
});
|
||||
|
||||
test('restoreLinuxOverlayWindowShape skips destroyed or unsupported windows', () => {
|
||||
assert.equal(
|
||||
restoreLinuxOverlayWindowShape({
|
||||
isDestroyed: () => true,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||
setShape: () => {
|
||||
throw new Error('should not shape destroyed windows');
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
restoreLinuxOverlayWindowShape({
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export type LinuxOverlayShapeRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type LinuxOverlayShapeWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
getBounds?: () => LinuxOverlayShapeRect;
|
||||
setShape?: (rects: LinuxOverlayShapeRect[]) => void;
|
||||
};
|
||||
|
||||
function toPositivePixel(value: number): number | null {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.round(value));
|
||||
}
|
||||
|
||||
export function buildFullWindowShapeRect(
|
||||
bounds: LinuxOverlayShapeRect,
|
||||
): LinuxOverlayShapeRect | null {
|
||||
const width = toPositivePixel(bounds.width);
|
||||
const height = toPositivePixel(bounds.height);
|
||||
if (width === null || height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreLinuxOverlayWindowShape(window: LinuxOverlayShapeWindow | null): boolean {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.setShape !== 'function' || typeof window.getBounds !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = buildFullWindowShapeRect(window.getBounds());
|
||||
if (!rect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.setShape([rect]);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
ensureLinuxOverlayZOrderKeepAliveLoop,
|
||||
type LinuxOverlayZOrderKeepAliveDeps,
|
||||
shouldRunLinuxOverlayZOrderKeepAlive,
|
||||
stopLinuxOverlayZOrderKeepAliveLoop,
|
||||
tickLinuxOverlayZOrderKeepAlive,
|
||||
} from './linux-overlay-zorder-keepalive';
|
||||
|
||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (original) Object.defineProperty(process, 'platform', original);
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<LinuxOverlayZOrderKeepAliveDeps>,
|
||||
calls: string[],
|
||||
): LinuxOverlayZOrderKeepAliveDeps {
|
||||
return {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({ isDestroyed: () => false, isVisible: () => true }),
|
||||
isTrackingMpvWindow: () => true,
|
||||
isMpvWindowFocused: () => true,
|
||||
isOverlayWindowFocused: () => false,
|
||||
shouldSuppressReassert: () => false,
|
||||
raiseMpvWindow: async () => {
|
||||
calls.push('raise-mpv');
|
||||
return true;
|
||||
},
|
||||
releaseOverlayLayerOrder: () => calls.push('release'),
|
||||
enforceOverlayLayerOrder: () => calls.push('enforce'),
|
||||
focusOverlayWindow: () => calls.push('focus-overlay'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('shouldRunLinuxOverlayZOrderKeepAlive runs on Linux except Hyprland/Sway', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ HYPRLAND_INSTANCE_SIGNATURE: 'h' }), false);
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ SWAYSOCK: '/tmp/s' }), false);
|
||||
});
|
||||
withPlatform('win32', () => {
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({}), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('tick re-asserts overlay level when the overlay is shown and unobstructed', async () => {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(makeDeps({}, calls));
|
||||
assert.deepEqual(calls, ['enforce']);
|
||||
});
|
||||
|
||||
test('tick raises mpv behind a focused overlay when mpv is behind another app', async () => {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(
|
||||
makeDeps(
|
||||
{
|
||||
isMpvWindowFocused: () => false,
|
||||
isOverlayWindowFocused: () => true,
|
||||
},
|
||||
calls,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(calls, ['raise-mpv', 'enforce', 'focus-overlay']);
|
||||
});
|
||||
|
||||
test('tick releases stale overlay topmost when another app is focused', async () => {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(
|
||||
makeDeps(
|
||||
{
|
||||
isMpvWindowFocused: () => false,
|
||||
isOverlayWindowFocused: () => false,
|
||||
},
|
||||
calls,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(calls, ['release']);
|
||||
});
|
||||
|
||||
test('tick skips when overlay hidden, mpv untracked, suppressed, or window gone', async () => {
|
||||
for (const override of [
|
||||
{ getVisibleOverlayVisible: () => false },
|
||||
{ isTrackingMpvWindow: () => false },
|
||||
{ shouldSuppressReassert: () => true },
|
||||
{ getMainWindow: () => null },
|
||||
{ getMainWindow: () => ({ isDestroyed: () => true, isVisible: () => true }) },
|
||||
{ getMainWindow: () => ({ isDestroyed: () => false, isVisible: () => false }) },
|
||||
] satisfies Array<Partial<LinuxOverlayZOrderKeepAliveDeps>>) {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(makeDeps(override, calls));
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('keep-alive loop skips overlapping ticks and resets after async completion', async () => {
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
const originalClearInterval = globalThis.clearInterval;
|
||||
let intervalCallback: (() => void) | null = null;
|
||||
let resolveRaise: (() => void) | null = null;
|
||||
let raiseCalls = 0;
|
||||
|
||||
globalThis.setInterval = ((callback: () => void) => {
|
||||
intervalCallback = callback;
|
||||
return { unref: () => {} } as ReturnType<typeof setInterval>;
|
||||
}) as typeof setInterval;
|
||||
globalThis.clearInterval = (() => {}) as typeof clearInterval;
|
||||
|
||||
try {
|
||||
withPlatform('linux', () => {
|
||||
ensureLinuxOverlayZOrderKeepAliveLoop(
|
||||
makeDeps(
|
||||
{
|
||||
isMpvWindowFocused: () => false,
|
||||
isOverlayWindowFocused: () => true,
|
||||
raiseMpvWindow: async () => {
|
||||
raiseCalls += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveRaise = resolve;
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[],
|
||||
),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
assert.ok(intervalCallback);
|
||||
const tick = intervalCallback as () => void;
|
||||
tick();
|
||||
tick();
|
||||
assert.equal(raiseCalls, 1);
|
||||
|
||||
assert.ok(resolveRaise);
|
||||
const finishRaise = resolveRaise as () => void;
|
||||
finishRaise();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
tick();
|
||||
assert.equal(raiseCalls, 2);
|
||||
} finally {
|
||||
stopLinuxOverlayZOrderKeepAliveLoop();
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
globalThis.clearInterval = originalClearInterval;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
||||
|
||||
/*
|
||||
Linux overlay z-order keep-alive loop.
|
||||
|
||||
The visible overlay re-asserts its always-on-top level only when mpv's geometry changes
|
||||
(the bounds-update path) or on a fullscreen toggle (the fullscreen refresh burst). When mpv
|
||||
is raised above the overlay WITHOUT a geometry change — click-to-raise, focus change, or a
|
||||
compositor restack on KDE/GNOME/other X11/XWayland window managers — nothing re-raises the
|
||||
overlay and it stays buried. Windows guards against this with a foreground poll loop; this is
|
||||
the Linux equivalent: a lightweight periodic re-assert while the overlay is shown and mpv
|
||||
remains the foreground window. If another app is active, the overlay releases its global
|
||||
keep-above level so that app can cover it.
|
||||
|
||||
Gated to X11/XWayland sessions (not Hyprland/Sway, which place the overlay natively and would
|
||||
otherwise be spammed with hyprctl dispatches).
|
||||
*/
|
||||
|
||||
type KeepAliveOverlayWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
export type LinuxOverlayZOrderKeepAliveDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => KeepAliveOverlayWindow | null;
|
||||
isTrackingMpvWindow: () => boolean;
|
||||
isMpvWindowFocused: () => boolean;
|
||||
isOverlayWindowFocused: () => boolean;
|
||||
/** True when a modal/stats overlay or active interaction owns the top — skip re-asserting. */
|
||||
shouldSuppressReassert: () => boolean;
|
||||
raiseMpvWindow: () => Promise<boolean>;
|
||||
releaseOverlayLayerOrder: () => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
focusOverlayWindow?: () => void;
|
||||
};
|
||||
|
||||
export const LINUX_OVERLAY_ZORDER_KEEPALIVE_INTERVAL_MS = 700;
|
||||
|
||||
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let keepAliveTickInFlight = false;
|
||||
|
||||
export function shouldRunLinuxOverlayZOrderKeepAlive(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return process.platform === 'linux' && !isSupportedWaylandCompositor(env);
|
||||
}
|
||||
|
||||
export async function tickLinuxOverlayZOrderKeepAlive(
|
||||
deps: LinuxOverlayZOrderKeepAliveDeps,
|
||||
): Promise<void> {
|
||||
if (!deps.getVisibleOverlayVisible()) return;
|
||||
if (!deps.isTrackingMpvWindow()) return;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayFocused = deps.isOverlayWindowFocused();
|
||||
const mpvFocused = deps.isMpvWindowFocused();
|
||||
if (!mpvFocused && !overlayFocused) {
|
||||
deps.releaseOverlayLayerOrder();
|
||||
return;
|
||||
}
|
||||
if (deps.shouldSuppressReassert()) return;
|
||||
|
||||
if (overlayFocused && !mpvFocused) {
|
||||
await deps.raiseMpvWindow();
|
||||
}
|
||||
deps.enforceOverlayLayerOrder();
|
||||
if (overlayFocused && !mpvFocused) {
|
||||
deps.focusOverlayWindow?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureLinuxOverlayZOrderKeepAliveLoop(
|
||||
deps: LinuxOverlayZOrderKeepAliveDeps,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
if (keepAliveInterval !== null) return;
|
||||
if (!shouldRunLinuxOverlayZOrderKeepAlive(env)) return;
|
||||
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (keepAliveTickInFlight) return;
|
||||
keepAliveTickInFlight = true;
|
||||
void tickLinuxOverlayZOrderKeepAlive(deps)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
keepAliveTickInFlight = false;
|
||||
});
|
||||
}, LINUX_OVERLAY_ZORDER_KEEPALIVE_INTERVAL_MS);
|
||||
keepAliveInterval.unref?.();
|
||||
}
|
||||
|
||||
export function stopLinuxOverlayZOrderKeepAliveLoop(): void {
|
||||
if (keepAliveInterval === null) return;
|
||||
clearInterval(keepAliveInterval);
|
||||
keepAliveInterval = null;
|
||||
keepAliveTickInFlight = false;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveLinuxVisibleOverlayWindowModeAction,
|
||||
shouldExitFullscreenOverrideForTrackedGeometry,
|
||||
} from './linux-visible-overlay-window-mode';
|
||||
|
||||
test('linux overlay mode sync records fullscreen without creating a hidden overlay', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'managed',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: false,
|
||||
visibleOverlayVisible: false,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync destroys stale hidden window without replacing it', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'managed',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: true,
|
||||
visibleOverlayVisible: false,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: true,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync replaces visible window when fullscreen mode changes', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'managed',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: true,
|
||||
visibleOverlayVisible: true,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: true,
|
||||
shouldDestroyCurrentWindow: true,
|
||||
shouldRefreshVisibleOverlay: true,
|
||||
createWindowTiming: 'after-current-destroyed',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync creates correct visible window when none exists', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'fullscreen-override',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: false,
|
||||
visibleOverlayVisible: true,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: true,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: true,
|
||||
createWindowTiming: 'now',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync no-ops when live window already matches mode', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'fullscreen-override',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: true,
|
||||
visibleOverlayVisible: true,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode exits fullscreen override when tracked geometry is windowed', () => {
|
||||
assert.equal(
|
||||
shouldExitFullscreenOverrideForTrackedGeometry({
|
||||
currentMode: 'fullscreen-override',
|
||||
trackedFullscreen: true,
|
||||
geometry: { x: 420, y: 90, width: 1280, height: 720 },
|
||||
displayBounds: { x: 0, y: 0, width: 2560, height: 1440 },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
export type LinuxVisibleOverlayWindowMode = 'managed' | 'fullscreen-override';
|
||||
|
||||
type LinuxVisibleOverlayGeometry = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type LinuxVisibleOverlayWindowModeAction = {
|
||||
nextMode: LinuxVisibleOverlayWindowMode;
|
||||
shouldCreateWindow: boolean;
|
||||
shouldDestroyCurrentWindow: boolean;
|
||||
shouldRefreshVisibleOverlay: boolean;
|
||||
createWindowTiming: 'none' | 'now' | 'after-current-destroyed';
|
||||
};
|
||||
|
||||
export function resolveLinuxVisibleOverlayWindowModeAction(options: {
|
||||
currentMode: LinuxVisibleOverlayWindowMode;
|
||||
fullscreen: boolean;
|
||||
hasLiveWindow: boolean;
|
||||
visibleOverlayVisible: boolean;
|
||||
}): LinuxVisibleOverlayWindowModeAction {
|
||||
const nextMode: LinuxVisibleOverlayWindowMode = options.fullscreen
|
||||
? 'fullscreen-override'
|
||||
: 'managed';
|
||||
const modeChanged = options.currentMode !== nextMode;
|
||||
|
||||
if (!options.visibleOverlayVisible) {
|
||||
return {
|
||||
nextMode,
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: options.hasLiveWindow && modeChanged,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
if (options.hasLiveWindow && !modeChanged) {
|
||||
return {
|
||||
nextMode,
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextMode,
|
||||
shouldCreateWindow: true,
|
||||
shouldDestroyCurrentWindow: options.hasLiveWindow,
|
||||
shouldRefreshVisibleOverlay: true,
|
||||
createWindowTiming: options.hasLiveWindow ? 'after-current-destroyed' : 'now',
|
||||
};
|
||||
}
|
||||
|
||||
function geometryCoversDisplayBounds(
|
||||
geometry: LinuxVisibleOverlayGeometry,
|
||||
displayBounds: LinuxVisibleOverlayGeometry,
|
||||
tolerancePx: number,
|
||||
): boolean {
|
||||
const geometryRight = geometry.x + geometry.width;
|
||||
const geometryBottom = geometry.y + geometry.height;
|
||||
const displayRight = displayBounds.x + displayBounds.width;
|
||||
const displayBottom = displayBounds.y + displayBounds.height;
|
||||
|
||||
return (
|
||||
geometry.x <= displayBounds.x + tolerancePx &&
|
||||
geometry.y <= displayBounds.y + tolerancePx &&
|
||||
geometryRight >= displayRight - tolerancePx &&
|
||||
geometryBottom >= displayBottom - tolerancePx
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldExitFullscreenOverrideForTrackedGeometry(options: {
|
||||
currentMode: LinuxVisibleOverlayWindowMode;
|
||||
trackedFullscreen: boolean;
|
||||
geometry: LinuxVisibleOverlayGeometry;
|
||||
displayBounds: LinuxVisibleOverlayGeometry;
|
||||
tolerancePx?: number;
|
||||
}): boolean {
|
||||
if (options.currentMode !== 'fullscreen-override') return false;
|
||||
if (!options.trackedFullscreen) return false;
|
||||
return !geometryCoversDisplayBounds(
|
||||
options.geometry,
|
||||
options.displayBounds,
|
||||
options.tolerancePx ?? 2,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createLinuxX11CursorPointReader,
|
||||
parseXdotoolMouseLocation,
|
||||
} from './linux-x11-cursor-point';
|
||||
|
||||
test('parseXdotoolMouseLocation parses root cursor coordinates', () => {
|
||||
assert.deepEqual(
|
||||
parseXdotoolMouseLocation(`X=1700
|
||||
Y=1050
|
||||
SCREEN=0
|
||||
WINDOW=44040194
|
||||
`),
|
||||
{ x: 1700, y: 1050 },
|
||||
);
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader returns cached X11 cursor point over stale fallback', async () => {
|
||||
let now = 1000;
|
||||
const pendingCommand: { resolve?: (value: string) => void } = {};
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: { DISPLAY: ':1' },
|
||||
platform: 'linux',
|
||||
now: () => now,
|
||||
runCommand: (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return new Promise((resolve) => {
|
||||
pendingCommand.resolve = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 877, y: 718 });
|
||||
assert.deepEqual(calls, [{ command: 'xdotool', args: ['getmouselocation', '--shell'] }]);
|
||||
|
||||
assert.ok(pendingCommand.resolve);
|
||||
pendingCommand.resolve(`X=1700
|
||||
Y=1050
|
||||
SCREEN=0
|
||||
WINDOW=44040194
|
||||
`);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
now += 60;
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 1700, y: 1050 });
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader does not spawn off X11 Linux', () => {
|
||||
const calls: string[] = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: {},
|
||||
platform: 'linux',
|
||||
runCommand: async (command) => {
|
||||
calls.push(command);
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 5, y: 6 }), { x: 5, y: 6 });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader does not spawn for supported native Wayland compositors', () => {
|
||||
const calls: string[] = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
|
||||
},
|
||||
platform: 'linux',
|
||||
runCommand: async (command) => {
|
||||
calls.push(command);
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 7, y: 8 }), { x: 7, y: 8 });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { getLinuxDesktopEnv, isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
||||
import type { PointerPoint } from './linux-overlay-pointer-interaction';
|
||||
|
||||
type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
||||
|
||||
const XDOTOOL_CURSOR_ARGS = ['getmouselocation', '--shell'] as const;
|
||||
const CURSOR_POINT_MAX_AGE_MS = 1000;
|
||||
const COMMAND_FAILURE_RETRY_DELAY_MS = 1000;
|
||||
|
||||
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseXdotoolMouseLocation(raw: string): PointerPoint | null {
|
||||
const xMatch = raw.match(/^X=(-?\d+)$/m);
|
||||
const yMatch = raw.match(/^Y=(-?\d+)$/m);
|
||||
if (!xMatch || !yMatch) return null;
|
||||
|
||||
const x = Number.parseInt(xMatch[1]!, 10);
|
||||
const y = Number.parseInt(yMatch[1]!, 10);
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function createLinuxX11CursorPointReader(options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => number;
|
||||
platform?: NodeJS.Platform;
|
||||
runCommand?: CommandRunner;
|
||||
}) {
|
||||
const env = options?.env ?? process.env;
|
||||
const now = options?.now ?? (() => Date.now());
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const runCommand = options?.runCommand ?? execFileUtf8;
|
||||
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
|
||||
let inFlight = false;
|
||||
let retryAfterMs = 0;
|
||||
|
||||
function isSupported(): boolean {
|
||||
if (platform !== 'linux' || !env.DISPLAY?.trim()) return false;
|
||||
if (getLinuxDesktopEnv(env).hasWayland && isSupportedWaylandCompositor(env)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
const nowMs = now();
|
||||
if (!isSupported() || inFlight || nowMs < retryAfterMs) return;
|
||||
|
||||
inFlight = true;
|
||||
void runCommand('xdotool', [...XDOTOOL_CURSOR_ARGS])
|
||||
.then((raw) => {
|
||||
const point = parseXdotoolMouseLocation(raw);
|
||||
if (!point) {
|
||||
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
||||
return;
|
||||
}
|
||||
latest = { point, updatedAtMs: now() };
|
||||
retryAfterMs = 0;
|
||||
})
|
||||
.catch(() => {
|
||||
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getCursorScreenPoint(fallback: PointerPoint): PointerPoint {
|
||||
refresh();
|
||||
if (latest && now() - latest.updatedAtMs <= CURSOR_POINT_MAX_AGE_MS) {
|
||||
return latest.point;
|
||||
}
|
||||
return fallback;
|
||||
},
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
createHandleMpvTimePosChangeHandler,
|
||||
} from './mpv-main-event-actions';
|
||||
|
||||
test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
test('subtitle change handler updates state and forwards uncached text without raw broadcast', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
@@ -23,7 +23,22 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
});
|
||||
|
||||
handler({ text: 'line' });
|
||||
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
|
||||
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler clears immediately for empty subtitle text', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: () => null,
|
||||
broadcastSubtitle: (payload) =>
|
||||
calls.push(`broadcast:${payload.text}:${payload.tokens === null ? 'plain' : 'annotated'}`),
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ text: '' });
|
||||
assert.deepEqual(calls, ['set:', 'broadcast::plain', 'process:', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler broadcasts cached annotated payload immediately when available', () => {
|
||||
|
||||
@@ -28,10 +28,12 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
deps.onSubtitleChange(text);
|
||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||
} else {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
if (!text.trim()) {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
deps.onSubtitleChange(text);
|
||||
}
|
||||
deps.refreshDiscordPresence();
|
||||
|
||||
@@ -28,6 +28,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
},
|
||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
@@ -82,7 +83,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
|
||||
assert.ok(calls.includes('set-sub:line'));
|
||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||
assert.ok(calls.includes('broadcast-sub:line'));
|
||||
assert.equal(calls.includes('broadcast-sub:line'), true);
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
|
||||
@@ -118,7 +118,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
|
||||
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
|
||||
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
||||
? (payload) => deps.emitImmediateSubtitle?.(payload)
|
||||
: undefined,
|
||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
|
||||
@@ -75,18 +75,66 @@ test('overlay modal input state restores main window focus on deactivation', ()
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: () => {},
|
||||
syncOverlayVisibilityForModal: () => {},
|
||||
syncOverlayShortcutsForModal: (isActive) => {
|
||||
calls.push(`shortcuts:${isActive}`);
|
||||
},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
calls.push('restore-focus');
|
||||
},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
assert.deepEqual(calls, []);
|
||||
calls.length = 0;
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
assert.deepEqual(calls, ['restore-focus']);
|
||||
assert.deepEqual(calls, ['shortcuts:false', 'visibility', 'restore-focus', 'visibility']);
|
||||
});
|
||||
|
||||
test('overlay modal input state schedules visibility settle burst after focus restore', () => {
|
||||
const modalWindow = createModalWindow();
|
||||
const calls: string[] = [];
|
||||
const scheduled: Array<{ delayMs: number; callback: () => void }> = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: () => {},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
calls.push('restore-focus');
|
||||
},
|
||||
schedulePostRestoreVisibilitySync: (callback, delayMs) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
return scheduled.length as never;
|
||||
},
|
||||
clearPostRestoreVisibilitySync: () => {},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
calls.length = 0;
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
|
||||
assert.deepEqual(
|
||||
scheduled.map((entry) => entry.delayMs),
|
||||
[50, 150, 300, 600, 1000],
|
||||
);
|
||||
for (const entry of scheduled) {
|
||||
entry.callback();
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
'visibility',
|
||||
'restore-focus',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay modal input state is idempotent for unchanged state', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
type VisibilitySyncTimeout = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
const POST_RESTORE_VISIBILITY_SYNC_DELAYS_MS = [50, 150, 300, 600, 1000] as const;
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
@@ -25,16 +28,48 @@ export type OverlayModalInputStateDeps = {
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
restoreMainWindowFocus?: () => void;
|
||||
schedulePostRestoreVisibilitySync?: (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => VisibilitySyncTimeout;
|
||||
clearPostRestoreVisibilitySync?: (timeout: VisibilitySyncTimeout) => void;
|
||||
};
|
||||
|
||||
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
let modalInputExclusive = false;
|
||||
let postRestoreVisibilitySyncTimeouts: VisibilitySyncTimeout[] = [];
|
||||
const schedulePostRestoreVisibilitySync =
|
||||
deps.schedulePostRestoreVisibilitySync ?? globalThis.setTimeout;
|
||||
const clearPostRestoreVisibilitySync =
|
||||
deps.clearPostRestoreVisibilitySync ?? globalThis.clearTimeout;
|
||||
|
||||
const clearPostRestoreVisibilitySyncBurst = (): void => {
|
||||
for (const timeout of postRestoreVisibilitySyncTimeouts) {
|
||||
clearPostRestoreVisibilitySync(timeout);
|
||||
}
|
||||
postRestoreVisibilitySyncTimeouts = [];
|
||||
};
|
||||
|
||||
const schedulePostRestoreVisibilitySyncBurst = (): void => {
|
||||
clearPostRestoreVisibilitySyncBurst();
|
||||
for (const delayMs of POST_RESTORE_VISIBILITY_SYNC_DELAYS_MS) {
|
||||
const timeout = schedulePostRestoreVisibilitySync(() => {
|
||||
postRestoreVisibilitySyncTimeouts = postRestoreVisibilitySyncTimeouts.filter(
|
||||
(candidate) => candidate !== timeout,
|
||||
);
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
}, delayMs);
|
||||
(timeout as { unref?: () => void }).unref?.();
|
||||
postRestoreVisibilitySyncTimeouts.push(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalInputStateChange = (isActive: boolean): void => {
|
||||
if (modalInputExclusive === isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPostRestoreVisibilitySyncBurst();
|
||||
modalInputExclusive = isActive;
|
||||
if (isActive) {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
@@ -54,6 +89,10 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
if (!isActive) {
|
||||
deps.restoreMainWindowFocus?.();
|
||||
if (deps.restoreMainWindowFocus) {
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
schedulePostRestoreVisibilitySyncBurst();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
getModalActive: () => deps.getModalActive(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getNonNativeInputRegionActive: () => deps.getNonNativeInputRegionActive?.() ?? false,
|
||||
getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false,
|
||||
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
@@ -31,6 +32,8 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
isOverlayVisible: (kind) => kind === 'visible',
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onVisibleWindowFocused: () => calls.push('visible-focus'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
});
|
||||
@@ -27,12 +28,17 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
||||
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
|
||||
overlayDeps.forwardTabToMpv();
|
||||
overlayDeps.onVisibleWindowFocused?.();
|
||||
|
||||
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
||||
getMainWindow: () => null,
|
||||
isWindowDestroyed: () => false,
|
||||
createOverlayWindow: () => ({ id: 'visible' }),
|
||||
setMainWindow: () => calls.push('set-main'),
|
||||
});
|
||||
const mainDeps = buildMainDeps();
|
||||
assert.equal(mainDeps.getMainWindow(), null);
|
||||
assert.equal(mainDeps.isWindowDestroyed({ id: 'visible' }), false);
|
||||
mainDeps.setMainWindow(null);
|
||||
|
||||
const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({
|
||||
@@ -42,5 +48,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
const modalDeps = buildModalDeps();
|
||||
modalDeps.setModalWindow(null);
|
||||
|
||||
assert.deepEqual(calls, ['forward-tab', 'set-main', 'set-modal']);
|
||||
assert.deepEqual(calls, ['forward-tab', 'visible-focus', 'set-main', 'set-modal']);
|
||||
});
|
||||
|
||||
@@ -11,9 +11,11 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
@@ -24,9 +26,11 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
getLinuxX11FullscreenOverlay?: () => boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -38,7 +42,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onVisibleWindowFocused: deps.onVisibleWindowFocused,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
@@ -46,10 +52,14 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
}
|
||||
|
||||
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
|
||||
getMainWindow: () => TWindow | null;
|
||||
isWindowDestroyed: (window: TWindow) => boolean;
|
||||
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
isWindowDestroyed: (window: TWindow) => deps.isWindowDestroyed(window),
|
||||
createOverlayWindow: deps.createOverlayWindow,
|
||||
setMainWindow: deps.setMainWindow,
|
||||
});
|
||||
|
||||
@@ -18,9 +18,10 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
assert.equal(options.isOverlayVisible('modal'), false);
|
||||
assert.equal(options.yomitanSession, yomitanSession);
|
||||
options.forwardTabToMpv();
|
||||
options.onVisibleWindowFocused?.();
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.setOverlayDebugVisualizationEnabled(true);
|
||||
options.onWindowClosed(kind);
|
||||
options.onWindowClosed(kind, window);
|
||||
return window;
|
||||
},
|
||||
isDev: true,
|
||||
@@ -30,7 +31,9 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
isOverlayVisible: (kind) => kind === 'visible',
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
onVisibleWindowFocused: () => calls.push('visible-focus'),
|
||||
onWindowClosed: (kind, closedWindow) =>
|
||||
calls.push(`closed:${kind}:${(closedWindow as { id: number }).id}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
});
|
||||
|
||||
@@ -38,27 +41,51 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
assert.deepEqual(calls, [
|
||||
'kind:visible',
|
||||
'forward-tab',
|
||||
'visible-focus',
|
||||
'runtime-options',
|
||||
'debug:true',
|
||||
'closed:visible',
|
||||
'closed:visible:1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('create main window handler stores visible window', () => {
|
||||
const calls: string[] = [];
|
||||
const visibleWindow = { id: 'visible' };
|
||||
let mainWindow: typeof visibleWindow | null = null;
|
||||
const createMainWindow = createCreateMainWindowHandler({
|
||||
getMainWindow: () => mainWindow,
|
||||
isWindowDestroyed: () => false,
|
||||
createOverlayWindow: (kind) => {
|
||||
calls.push(`create:${kind}`);
|
||||
return visibleWindow;
|
||||
},
|
||||
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
calls.push(`set:${(window as { id: string }).id}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(createMainWindow(), visibleWindow);
|
||||
assert.deepEqual(calls, ['create:visible', 'set:visible']);
|
||||
});
|
||||
|
||||
test('create main window handler reuses an existing live visible window', () => {
|
||||
const calls: string[] = [];
|
||||
const existingWindow = { id: 'existing' };
|
||||
const createMainWindow = createCreateMainWindowHandler({
|
||||
getMainWindow: () => existingWindow,
|
||||
isWindowDestroyed: () => false,
|
||||
createOverlayWindow: (kind) => {
|
||||
calls.push(`create:${kind}`);
|
||||
return { id: 'created' };
|
||||
},
|
||||
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
|
||||
});
|
||||
|
||||
assert.equal(createMainWindow(), existingWindow);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('create modal window handler stores modal window', () => {
|
||||
const calls: string[] = [];
|
||||
const modalWindow = { id: 'modal' };
|
||||
|
||||
@@ -13,9 +13,11 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
@@ -26,9 +28,11 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
getLinuxX11FullscreenOverlay?: () => boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
return (kind: OverlayWindowKind): TWindow => {
|
||||
@@ -40,7 +44,10 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
linuxX11FullscreenOverlay:
|
||||
kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onVisibleWindowFocused: deps.onVisibleWindowFocused,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
@@ -49,10 +56,16 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
}
|
||||
|
||||
export function createCreateMainWindowHandler<TWindow>(deps: {
|
||||
getMainWindow: () => TWindow | null;
|
||||
isWindowDestroyed: (window: TWindow) => boolean;
|
||||
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
return (): TWindow => {
|
||||
const existingWindow = deps.getMainWindow();
|
||||
if (existingWindow && !deps.isWindowDestroyed(existingWindow)) {
|
||||
return existingWindow;
|
||||
}
|
||||
const window = deps.createOverlayWindow('visible');
|
||||
deps.setMainWindow(window);
|
||||
return window;
|
||||
|
||||
@@ -10,8 +10,13 @@ test('overlay window layout main deps builders map callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const visible = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
getCurrentOverlayWindowBounds: () => {
|
||||
calls.push('visible-current');
|
||||
return null;
|
||||
},
|
||||
setOverlayWindowBounds: () => calls.push('visible'),
|
||||
})();
|
||||
assert.equal(visible.getCurrentOverlayWindowBounds?.(), null);
|
||||
visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 });
|
||||
|
||||
const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
@@ -42,6 +47,7 @@ test('overlay window layout main deps builders map callbacks', () => {
|
||||
order.ensureOverlayWindowLevel({});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'visible-current',
|
||||
'visible',
|
||||
'ensure-suppressed-check',
|
||||
'ensure',
|
||||
|
||||
@@ -14,6 +14,9 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
||||
deps: UpdateVisibleOverlayBoundsMainDeps,
|
||||
) {
|
||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||
getCurrentOverlayWindowBounds: () => deps.getCurrentOverlayWindowBounds?.() ?? null,
|
||||
shouldRefreshUnchangedGeometry: (geometry) =>
|
||||
deps.shouldRefreshUnchangedGeometry?.(geometry) ?? false,
|
||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
hasLiveOverlayWindowBoundsMismatch,
|
||||
} from './overlay-window-layout';
|
||||
|
||||
test('visible bounds handler writes visible layer geometry', () => {
|
||||
@@ -32,6 +33,72 @@ test('visible bounds handler runs follow-up callback after applying geometry', (
|
||||
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
|
||||
});
|
||||
|
||||
test('visible bounds handler skips unchanged geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
getCurrentOverlayWindowBounds: () => ({ ...geometry }),
|
||||
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||
afterSetOverlayWindowBounds: () => calls.push('after-bounds'),
|
||||
});
|
||||
|
||||
handleVisible(geometry);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('visible bounds handler can refresh unchanged geometry for mode reconciliation', () => {
|
||||
const calls: string[] = [];
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
getCurrentOverlayWindowBounds: () => ({ ...geometry }),
|
||||
shouldRefreshUnchangedGeometry: (nextGeometry) => {
|
||||
assert.deepEqual(nextGeometry, geometry);
|
||||
calls.push('refresh-check');
|
||||
return true;
|
||||
},
|
||||
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||
afterSetOverlayWindowBounds: () => calls.push('after-bounds'),
|
||||
});
|
||||
|
||||
handleVisible(geometry);
|
||||
|
||||
assert.deepEqual(calls, ['refresh-check', 'set-bounds', 'after-bounds']);
|
||||
});
|
||||
|
||||
test('live overlay bounds mismatch forces refresh after window manager restore drift', () => {
|
||||
const geometry = { x: 100, y: 80, width: 1280, height: 720 };
|
||||
|
||||
assert.equal(
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
[
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 96, y: 76, width: 1300, height: 740 }),
|
||||
},
|
||||
],
|
||||
geometry,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
[
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ ...geometry }),
|
||||
},
|
||||
{
|
||||
isDestroyed: () => true,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1, height: 1 }),
|
||||
},
|
||||
],
|
||||
geometry,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
type OverlayBoundsWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
getBounds: () => WindowGeometry;
|
||||
};
|
||||
|
||||
function sameGeometry(a: WindowGeometry | null | undefined, b: WindowGeometry): boolean {
|
||||
return a?.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
|
||||
export function hasLiveOverlayWindowBoundsMismatch(
|
||||
windows: Array<OverlayBoundsWindow | null | undefined>,
|
||||
geometry: WindowGeometry,
|
||||
): boolean {
|
||||
return windows.some((window) => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
return !sameGeometry(window.getBounds(), geometry);
|
||||
});
|
||||
}
|
||||
|
||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
getCurrentOverlayWindowBounds?: () => WindowGeometry | null;
|
||||
shouldRefreshUnchangedGeometry?: (geometry: WindowGeometry) => boolean;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
if (
|
||||
sameGeometry(deps.getCurrentOverlayWindowBounds?.(), geometry) &&
|
||||
deps.shouldRefreshUnchangedGeometry?.(geometry) !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deps.setOverlayWindowBounds(geometry);
|
||||
deps.afterSetOverlayWindowBounds?.(geometry);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,8 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
},
|
||||
getMainWindow: () => mainWindow,
|
||||
isWindowDestroyed: () => false,
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ type CreateOverlayWindowMainDeps<TWindow> = Parameters<
|
||||
|
||||
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
|
||||
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
|
||||
getMainWindow: () => TWindow | null;
|
||||
isWindowDestroyed: (window: TWindow) => boolean;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
setModalWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
@@ -23,6 +25,8 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
|
||||
);
|
||||
const createMainWindow = createCreateMainWindowHandler<TWindow>(
|
||||
createBuildCreateMainWindowMainDepsHandler<TWindow>({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
isWindowDestroyed: (window) => deps.isWindowDestroyed(window),
|
||||
createOverlayWindow: (kind) => createOverlayWindow(kind),
|
||||
setMainWindow: (window) => deps.setMainWindow(window),
|
||||
})(),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveFreshPlaybackPaused } from './playback-paused-state';
|
||||
|
||||
test('resolveFreshPlaybackPaused prefers the live mpv pause property over cached state', async () => {
|
||||
const paused = await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => false,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? true : null),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(paused, true);
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused trusts cached paused state without probing mpv', async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
const paused = await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => {
|
||||
requestCount += 1;
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(paused, true);
|
||||
assert.equal(requestCount, 0);
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused normalizes mpv pause property strings and numbers', async () => {
|
||||
const values: Array<[unknown, boolean]> = [
|
||||
['yes', true],
|
||||
['no', false],
|
||||
['0', false],
|
||||
[1, true],
|
||||
[0, false],
|
||||
];
|
||||
|
||||
for (const [value, expected] of values) {
|
||||
const paused = await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => null,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => value,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(paused, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused falls back to cached state when mpv is unavailable', async () => {
|
||||
assert.equal(
|
||||
await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => true,
|
||||
getMpvClient: () => null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused treats cached playing state as unknown when live state is unavailable', async () => {
|
||||
assert.equal(
|
||||
await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => false,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => {
|
||||
throw new Error('socket closed');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
type PlaybackPausedMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function coercePlaybackPaused(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'no' || normalized === 'false' || normalized === '0') return false;
|
||||
if (normalized === 'yes' || normalized === 'true' || normalized === '1') return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveFreshPlaybackPaused(deps: {
|
||||
getCachedPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => PlaybackPausedMpvClient | null;
|
||||
}): Promise<boolean | null> {
|
||||
const cachedPaused = deps.getCachedPlaybackPaused();
|
||||
if (cachedPaused === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const client = deps.getMpvClient();
|
||||
if (client?.connected === true && typeof client.requestProperty === 'function') {
|
||||
try {
|
||||
const livePaused = coercePlaybackPaused(await client.requestProperty('pause'));
|
||||
if (livePaused !== null) {
|
||||
return livePaused;
|
||||
}
|
||||
} catch {
|
||||
// Avoid trusting a stale cached "playing" state for hover auto-pause.
|
||||
}
|
||||
}
|
||||
|
||||
return cachedPaused === false ? null : cachedPaused;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
|
||||
import {
|
||||
hasLiveSeparateWindow,
|
||||
shouldSuppressVisibleOverlayRaiseForSeparateWindow,
|
||||
} from './settings-window-z-order';
|
||||
|
||||
test('separate settings windows suppress visible overlay restacking', () => {
|
||||
const mainWindow = { id: 'overlay', isDestroyed: () => false };
|
||||
@@ -38,3 +41,20 @@ test('separate settings windows do not suppress unrelated or closed overlay work
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('live separate window detection ignores hidden and destroyed windows', () => {
|
||||
assert.equal(
|
||||
hasLiveSeparateWindow([
|
||||
{ isDestroyed: () => false, isVisible: () => false },
|
||||
{ isDestroyed: () => true, isVisible: () => true },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasLiveSeparateWindow([
|
||||
{ isDestroyed: () => false, isVisible: () => false },
|
||||
{ isDestroyed: () => false, isVisible: () => true },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
type SeparateWindowLike = {
|
||||
isDestroyed(): boolean;
|
||||
isVisible?: () => boolean;
|
||||
};
|
||||
|
||||
function hasLiveSeparateWindow(windows: Array<SeparateWindowLike | null | undefined>): boolean {
|
||||
return windows.some((window) => Boolean(window && !window.isDestroyed()));
|
||||
export function hasLiveSeparateWindow(
|
||||
windows: Array<SeparateWindowLike | null | undefined>,
|
||||
): boolean {
|
||||
return windows.some(
|
||||
(window) =>
|
||||
Boolean(window && !window.isDestroyed()) &&
|
||||
(typeof window?.isVisible !== 'function' || window.isVisible()),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSuppressVisibleOverlayRaiseForSeparateWindow(options: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isVisibleOverlayAutoplayTargetReady } from './visible-overlay-autoplay-readiness';
|
||||
import type { OverlayContentMeasurement } from '../../types';
|
||||
|
||||
const visibleMeasurement = (
|
||||
measuredAtMs: number,
|
||||
rect = { x: 100, y: 800, width: 500, height: 90 },
|
||||
): OverlayContentMeasurement => ({
|
||||
layer: 'visible',
|
||||
measuredAtMs,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: rect,
|
||||
interactiveRects: [rect],
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target waits for a fresh interactive subtitle measurement', () => {
|
||||
let measurement: OverlayContentMeasurement | null = null;
|
||||
const deps = {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
isOverlayWindowReady: () => true,
|
||||
getLatestVisibleMeasurement: () => measurement,
|
||||
};
|
||||
const signal = {
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '字幕', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
};
|
||||
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
|
||||
|
||||
measurement = visibleMeasurement(999);
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
|
||||
|
||||
measurement = visibleMeasurement(1_000, { x: 100, y: 800, width: 0, height: 90 });
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
|
||||
|
||||
measurement = visibleMeasurement(1_001);
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), true);
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target falls back when interactive rects have no area', () => {
|
||||
const ready = isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => true,
|
||||
isOverlayWindowReady: () => true,
|
||||
getLatestVisibleMeasurement: () => ({
|
||||
layer: 'visible',
|
||||
measuredAtMs: 2_000,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 100, y: 800, width: 500, height: 90 },
|
||||
interactiveRects: [{ x: 100, y: 800, width: 0, height: 90 }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '字幕', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target rejects synthetic warmup readiness', () => {
|
||||
const ready = isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => true,
|
||||
isOverlayWindowReady: () => true,
|
||||
getLatestVisibleMeasurement: () => visibleMeasurement(2_000),
|
||||
},
|
||||
{
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '__warm__', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(ready, false);
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target bypasses measurement when visible overlay is hidden', () => {
|
||||
const ready = isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => false,
|
||||
isOverlayWindowReady: () => false,
|
||||
getLatestVisibleMeasurement: () => null,
|
||||
},
|
||||
{
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '__warm__', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
|
||||
import type { AutoplayReadySignal } from './autoplay-ready-gate';
|
||||
|
||||
export type VisibleOverlayAutoplayReadinessDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
isOverlayWindowReady: () => boolean;
|
||||
getLatestVisibleMeasurement: () => OverlayContentMeasurement | null;
|
||||
};
|
||||
|
||||
function hasArea(rect: OverlayContentRect): boolean {
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function hasMeasuredInteractiveContent(measurement: OverlayContentMeasurement): boolean {
|
||||
const rects =
|
||||
Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.some(hasArea)
|
||||
? measurement.interactiveRects
|
||||
: measurement.contentRect
|
||||
? [measurement.contentRect]
|
||||
: [];
|
||||
|
||||
return rects.some(hasArea);
|
||||
}
|
||||
|
||||
export function isVisibleOverlayAutoplayTargetReady(
|
||||
deps: VisibleOverlayAutoplayReadinessDeps,
|
||||
signal: AutoplayReadySignal,
|
||||
): boolean {
|
||||
if (!deps.getVisibleOverlayVisible()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const subtitleText = signal.payload.text.trim();
|
||||
if (!subtitleText || subtitleText === '__warm__') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!deps.isOverlayWindowReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const measurement = deps.getLatestVisibleMeasurement();
|
||||
if (!measurement || measurement.measuredAtMs < signal.requestedAtMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasMeasuredInteractiveContent(measurement);
|
||||
}
|
||||
@@ -26,8 +26,42 @@ test('reloadOverlayWindowsForYomitanContentScripts reloads only live overlay win
|
||||
reload: () => calls.push('destroyed-webcontents'),
|
||||
},
|
||||
},
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
isLoading: () => true,
|
||||
reload: () => calls.push('loading-webcontents'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(reloadOverlayWindowsForYomitanContentScripts(windows), 1);
|
||||
assert.deepEqual(calls, ['live']);
|
||||
});
|
||||
|
||||
test('reloadOverlayWindowsForYomitanContentScripts retries loading webContents after load', () => {
|
||||
const calls: string[] = [];
|
||||
let finishLoad: (() => void) | null = null;
|
||||
const window = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
isLoading: () => true,
|
||||
once: (event: 'did-finish-load', listener: () => void) => {
|
||||
assert.equal(event, 'did-finish-load');
|
||||
finishLoad = listener;
|
||||
},
|
||||
reload: () => calls.push('reloaded-after-load'),
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(reloadOverlayWindowsForYomitanContentScripts([window]), 0);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
assert.ok(finishLoad);
|
||||
const finish = finishLoad as () => void;
|
||||
finish();
|
||||
|
||||
assert.deepEqual(calls, ['reloaded-after-load']);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
type ReloadableWebContents = {
|
||||
isDestroyed?: () => boolean;
|
||||
isLoading?: () => boolean;
|
||||
once?: (event: 'did-finish-load', listener: () => void) => void;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
@@ -8,6 +10,19 @@ type ReloadableOverlayWindow = {
|
||||
webContents?: ReloadableWebContents;
|
||||
};
|
||||
|
||||
function reloadWebContentsForYomitanContentScripts(
|
||||
webContents: ReloadableWebContents,
|
||||
logWarn?: (message: string, error: unknown) => void,
|
||||
): boolean {
|
||||
try {
|
||||
webContents.reload();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logWarn?.('Failed to reload overlay window after Yomitan extension load.', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function reloadOverlayWindowsForYomitanContentScripts(
|
||||
windows: ReloadableOverlayWindow[],
|
||||
logWarn?: (message: string, error: unknown) => void,
|
||||
@@ -23,12 +38,20 @@ export function reloadOverlayWindowsForYomitanContentScripts(
|
||||
if (!webContents || webContents.isDestroyed?.()) {
|
||||
continue;
|
||||
}
|
||||
if (webContents.isLoading?.()) {
|
||||
webContents.once?.('did-finish-load', () => {
|
||||
if (window.isDestroyed() || webContents.isDestroyed?.()) {
|
||||
return;
|
||||
}
|
||||
if (reloadWebContentsForYomitanContentScripts(webContents, logWarn)) {
|
||||
reloadCount += 1;
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
webContents.reload();
|
||||
if (reloadWebContentsForYomitanContentScripts(webContents, logWarn)) {
|
||||
reloadCount += 1;
|
||||
} catch (error) {
|
||||
logWarn?.('Failed to reload overlay window after Yomitan extension load.', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user