fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)

This commit is contained in:
2026-05-31 20:59:18 -07:00
committed by GitHub
parent b46b8dfa41
commit e1ea464bc9
103 changed files with 6314 additions and 353 deletions
+6
View File
@@ -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,
+197 -6
View File
@@ -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\(\)/);
+5
View File
@@ -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']],
);
});
+66 -34
View File
@@ -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', () => {
+6 -4
View File
@@ -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'));
+3 -1
View File
@@ -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' };
+15 -2
View File
@@ -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({
+29
View File
@@ -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,
);
});
+39
View File
@@ -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,
);
});
+9 -2
View File
@@ -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);
}
}