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
+66
View File
@@ -143,6 +143,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
activatePlaybackWindowForOverlayInteraction: () => false,
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
@@ -247,6 +248,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
focusMainWindow: () => {},
activatePlaybackWindowForOverlayInteraction: () => false,
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => ({}),
@@ -312,6 +314,28 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.equal(deps.getPlaybackPaused(), true);
});
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
const reports: unknown[] = [];
const deps = createIpcDepsRuntime({
getMainWindow: () => mainWindow,
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
} as unknown as Parameters<typeof createIpcDepsRuntime>[0]);
const report = deps.reportOverlayContentBounds as (
payload: unknown,
senderWindow: unknown,
) => void;
report({ source: 'stale' }, staleWindow);
report({ source: 'main' }, mainWindow);
report({ source: 'missing' }, null);
assert.deepEqual(reports, [{ source: 'main' }]);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -334,6 +358,27 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const senderWindows: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
senderWindows.push(senderWindow);
}) as IpcServiceDeps['reportOverlayContentBounds'],
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
assert.equal(typeof handler, 'function');
handler?.({}, { layer: 'visible' });
assert.deepEqual(senderWindows, [null]);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -608,6 +653,27 @@ test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () =
assert.deepEqual(await handler!({}), snapshot);
});
test('registerIpcHandlers exposes playback window activation request', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
activatePlaybackWindowForOverlayInteraction: async () => {
calls.push('activate');
return true;
},
}),
registrar,
);
const handler = handlers.handle.get(
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
);
assert.ok(handler);
assert.equal(await handler!({}), true);
assert.deepEqual(calls, ['activate']);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
+48 -6
View File
@@ -49,6 +49,10 @@ export interface IpcServiceDeps {
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayInteractiveHint?: (
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -58,7 +62,8 @@ export interface IpcServiceDeps {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -81,6 +86,7 @@ export interface IpcServiceDeps {
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
@@ -89,7 +95,10 @@ export interface IpcServiceDeps {
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportOverlayContentBounds: (
payload: unknown,
senderWindow: ElectronBrowserWindow | null,
) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -229,6 +238,10 @@ export interface IpcDepsRuntimeOptions {
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayInteractiveHint?: (
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -236,7 +249,8 @@ export interface IpcDepsRuntimeOptions {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -254,6 +268,7 @@ export interface IpcDepsRuntimeOptions {
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
@@ -296,6 +311,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp,
@@ -310,6 +326,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle,
@@ -342,13 +359,21 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.focus();
},
activatePlaybackWindowForOverlayInteraction:
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
reportOverlayContentBounds: (payload, senderWindow) => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!senderWindow || senderWindow !== (mainWindow as unknown as ElectronBrowserWindow))
return;
options.reportOverlayContentBounds(payload);
},
getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup,
@@ -526,6 +551,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.getSubtitleSidebarSnapshot();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
return deps.getSubtitleSidebarOpen?.() ?? false;
});
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
return deps.getPlaybackPaused();
});
@@ -628,6 +657,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.focusMainWindow();
});
ipc.handle(IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction, async () => {
return (await deps.activatePlaybackWindowForOverlayInteraction?.()) ?? false;
});
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
const parsedRequest = parseSubsyncManualRunRequest(request);
if (!parsedRequest) {
@@ -668,8 +701,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.cycleRuntimeOption(parsedId, parsedDirection);
});
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (event: unknown, payload: unknown) => {
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.reportOverlayContentBounds(payload, senderWindow);
});
ipc.on(IPC_CHANNELS.command.reportOverlayInteractive, (event: unknown, interactive: unknown) => {
if (typeof interactive !== 'boolean') return;
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayInteractiveHint?.(interactive, senderWindow);
});
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
@@ -58,6 +58,50 @@ test('overlay measurement store keeps latest payload for visible layer', () => {
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
});
test('overlay measurement store clears stale visible measurements', () => {
const store = createOverlayContentMeasurementStore({
now: () => 1000,
warn: () => {
// noop
},
});
store.report({
layer: 'visible',
measuredAtMs: 900,
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
interactiveRects: [{ x: 50, y: 60, width: 400, height: 80 }],
});
assert.notEqual(store.getLatestByLayer('visible'), null);
store.clear('visible');
assert.equal(store.getLatestByLayer('visible'), null);
});
test('sanitizeOverlayContentMeasurement preserves separate interactive rects', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
interactiveRects: [
{ x: 50, y: 60, width: 400, height: 80 },
{ x: 100, y: 900, width: 500, height: 90 },
],
},
500,
);
assert.deepEqual(measurement?.interactiveRects, [
{ x: 50, y: 60, width: 400, height: 80 },
{ x: 100, y: 900, width: 500, height: 90 },
]);
});
test('overlay measurement store rate-limits invalid payload warnings', () => {
let now = 1_000;
const warnings: string[] = [];
@@ -5,6 +5,7 @@ const logger = createLogger('main:overlay-content-measurement');
const MAX_VIEWPORT = 10000;
const MAX_RECT_DIMENSION = 10000;
const MAX_RECT_OFFSET = 50000;
const MAX_INTERACTIVE_RECTS = 8;
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000;
@@ -26,6 +27,7 @@ export function sanitizeOverlayContentMeasurement(
width?: unknown;
height?: unknown;
} | null;
interactiveRects?: unknown;
};
if (candidate.layer !== 'visible') {
@@ -53,11 +55,21 @@ export function sanitizeOverlayContentMeasurement(
return null;
}
let interactiveRects: OverlayContentRect[] | undefined;
if (candidate.interactiveRects !== undefined) {
const sanitizedRects = sanitizeOverlayInteractiveRects(candidate.interactiveRects);
if (!sanitizedRects) {
return null;
}
interactiveRects = sanitizedRects;
}
return {
layer: candidate.layer,
measuredAtMs,
viewport: { width: viewportWidth, height: viewportHeight },
contentRect,
...(interactiveRects !== undefined ? { interactiveRects } : {}),
};
}
@@ -94,6 +106,22 @@ function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
return { x, y, width, height };
}
function sanitizeOverlayInteractiveRects(rects: unknown): OverlayContentRect[] | null {
if (!Array.isArray(rects) || rects.length > MAX_INTERACTIVE_RECTS) {
return null;
}
const sanitized: OverlayContentRect[] = [];
for (const rect of rects) {
const sanitizedRect = sanitizeOverlayContentRect(rect);
if (!sanitizedRect) {
return null;
}
sanitized.push(sanitizedRect);
}
return sanitized;
}
function readFiniteInRange(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
@@ -140,7 +168,12 @@ export function createOverlayContentMeasurementStore(options?: {
return latestByLayer[layer];
}
function clear(layer: OverlayLayer): void {
latestByLayer[layer] = null;
}
return {
clear,
getLatestByLayer,
report,
};
+273 -2
View File
@@ -62,6 +62,9 @@ function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setFullScreen: (fullscreen: boolean) => {
calls.push(`fullscreen:${fullscreen}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
@@ -259,6 +262,50 @@ test('non-native passive overlay stays click-through after subsequent visibility
assert.ok(calls.includes('mouse-ignore:true:forward'));
});
test('non-native shaped input region stays mouse-enabled without focusing the overlay', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: false,
nonNativeInputRegionActive: true,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
});
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -306,7 +353,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
assert.ok(!calls.includes('focus'));
});
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
test('untracked Linux overlay stays hidden when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
@@ -341,7 +388,8 @@ test('untracked non-macOS overlay shows passively when no tracker exists', () =>
} as never);
assert.equal(trackerWarning, false);
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd'));
@@ -384,6 +432,184 @@ test('passive Linux visible overlay does not take keyboard focus', () => {
assert.ok(!calls.includes('focus'));
});
test('passive Linux tracked overlay releases global topmost when mpv loses focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('fullscreen:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('passive Linux fullscreen override overlay hides when mpv loses focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
hideNonNativeOverlayWhenTargetUnfocused: true,
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('Linux active overlay interaction does not focus the overlay over fullscreen mpv', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
});
test('Linux active hover keeps global topmost when mpv loses focus and overlay is not focused', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -604,6 +830,51 @@ test('Windows visible overlay waits for content-ready before first reveal', () =
assert.ok(calls.includes('show-inactive'));
});
test('Linux visible overlay waits for content-ready before first reveal', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
setContentReady(false);
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
run();
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
setContentReady(true);
run();
assert.ok(calls.includes('show-inactive'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+37 -21
View File
@@ -18,6 +18,10 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const fullscreenWindow = window as BrowserWindow & {
setFullScreen?: (fullscreen: boolean) => void;
};
fullscreenWindow.setFullScreen?.(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
@@ -64,6 +68,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
nonNativeInputRegionActive?: boolean;
suspendVisibleOverlay?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
@@ -87,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: {
markOverlayLoadingOsdShown?: () => void;
resetOverlayLoadingOsdSuppression?: () => void;
resolveFallbackBounds?: () => WindowGeometry;
hideNonNativeOverlayWhenTargetUnfocused?: boolean;
}): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
return;
@@ -120,9 +126,9 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): boolean => {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const isVisibleOverlayWindowFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const isVisibleOverlayFocused = overlayInteractionActive || isVisibleOverlayWindowFocused;
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
@@ -181,12 +187,23 @@ export function updateVisibleOverlayVisibility(args: {
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const isNonNativePassiveOverlay =
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
const isNonNativeOverlay = !args.isWindowsPlatform && !args.isMacOSPlatform;
const isNonNativePassiveOverlay = isNonNativeOverlay && !overlayInteractionActive;
const hasNonNativeInputRegion =
isNonNativePassiveOverlay && args.nonNativeInputRegionActive === true;
const isTrackedNonNativeTargetFocused =
!args.isWindowsPlatform && !args.isMacOSPlatform && !!args.windowTracker
? (args.windowTracker.isTargetWindowFocused?.() ?? true)
: true;
const shouldReleaseNonNativeOverlayLevel =
isNonNativeOverlay &&
!!args.windowTracker &&
!isVisibleOverlayFocused &&
!isTrackedNonNativeTargetFocused;
const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough ||
forceMousePassthrough ||
isNonNativePassiveOverlay ||
(isNonNativePassiveOverlay && !hasNonNativeInputRegion) ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
@@ -214,6 +231,11 @@ export function updateVisibleOverlayVisibility(args: {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (shouldReleaseNonNativeOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (args.hideNonNativeOverlayWhenTargetUnfocused && wasVisible) {
mainWindow.hide();
}
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
@@ -223,7 +245,6 @@ export function updateVisibleOverlayVisibility(args: {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
if (
args.isWindowsPlatform &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
) {
@@ -238,7 +259,11 @@ export function updateVisibleOverlayVisibility(args: {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
if (hasNonNativeInputRegion) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
@@ -277,16 +302,7 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.focus();
}
if (
!args.isWindowsPlatform &&
!args.isMacOSPlatform &&
!forceMousePassthrough &&
overlayInteractionActive
) {
mainWindow.focus();
}
return !shouldReleaseMacOSOverlayLevel;
return !shouldReleaseNonNativeOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
@@ -385,9 +401,9 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
args.setTrackerNotReadyWarningShown(false);
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
@@ -10,6 +10,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.title, 'SubMiner Overlay');
assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.paintWhenInitiallyHidden, true);
assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
@@ -41,6 +42,59 @@ test('Linux visible overlay window allows compositor resize for mpv-sized placem
}
});
test('Linux visible overlay window stays managed so native apps can cover it', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
yomitanSession: null,
});
assert.equal(visibleOptions.alwaysOnTop, false);
assert.equal(visibleOptions.focusable, true);
assert.equal(modalOptions.focusable, true);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
linuxX11FullscreenOverlay: true,
yomitanSession: null,
});
assert.equal(visibleOptions.alwaysOnTop, true);
assert.equal(visibleOptions.focusable, false);
assert.equal(visibleOptions.resizable, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+1 -13
View File
@@ -69,23 +69,11 @@ export function handleOverlayWindowBlurred(options: {
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
if (options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
}
options.ensureOverlayWindowLevel();
if (options.kind === 'visible' && options.windowVisible) {
options.moveWindowTop();
}
return true;
}
+10 -3
View File
@@ -11,12 +11,18 @@ export function buildOverlayWindowOptions(
kind: OverlayWindowKind,
options: {
isDev: boolean;
linuxX11FullscreenOverlay?: boolean;
yomitanSession?: Session | null;
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
const isLinuxFullscreenOverlay =
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
const shouldStartAlwaysOnTop =
!(process.platform === 'win32' && kind === 'visible') &&
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
return {
show: false,
@@ -26,13 +32,14 @@ export function buildOverlayWindowOptions(
x: 0,
y: 0,
transparent: true,
paintWhenInitiallyHidden: true,
backgroundColor: '#00000000',
frame: false,
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: shouldAllowCompositorResize,
hasShadow: false,
focusable: true,
focusable: !isLinuxFullscreenOverlay,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
+81 -18
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ensureOverlayWindowLevel } from './overlay-window';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
@@ -166,6 +167,49 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
platform: 'linux',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'linux',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
@@ -189,25 +233,9 @@ test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback wi
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
const calls: string[] = [];
assert.equal(
handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-visible');
},
moveWindowTop: () => {
calls.push('move-visible');
},
platform: 'linux',
}),
true,
);
assert.equal(
handleOverlayWindowBlurred({
kind: 'modal',
@@ -223,5 +251,40 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
true,
);
assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']);
assert.deepEqual(calls, ['ensure-modal']);
});
test('ensureOverlayWindowLevel promotes Linux overlay above fullscreen mpv without changing workspaces', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
ensureOverlayWindowLevel({
getTitle: () => 'SubMiner Overlay',
moveTop: () => calls.push('move-top'),
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
} as never);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
assert.deepEqual(calls, [
'always-on-top:true:screen-saver:1',
'all-workspaces:true:fullscreen',
'move-top',
]);
});
+13 -3
View File
@@ -78,7 +78,9 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
window.moveTop();
return;
}
window.setAlwaysOnTop(true);
// Linux/X11 overlays start managed and only assert topmost while mpv owns the overlay layer.
// Focus loss releases this again so native Wayland apps can cover the overlay on KDE.
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
window.moveTop();
@@ -106,13 +108,16 @@ export function createOverlayWindow(
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void;
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
yomitanSession?: Session | null;
},
): BrowserWindow {
const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options));
window.setSkipTaskbar(true);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
@@ -172,7 +177,7 @@ export function createOverlayWindow(
window.hide();
window.on('closed', () => {
options.onWindowClosed(kind);
options.onWindowClosed(kind, window);
});
window.on('blur', () => {
@@ -192,6 +197,11 @@ export function createOverlayWindow(
});
});
window.on('focus', () => {
if (window.isDestroyed() || kind !== 'visible') return;
options.onVisibleWindowFocused?.();
});
if (options.isDev && kind === 'visible') {
window.webContents.openDevTools({ mode: 'detach' });
}
@@ -516,3 +516,28 @@ test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session
},
});
});
test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-count multi-line actions', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
copySubtitleMultiple: 'Ctrl+Shift+C',
mineSentenceMultiple: 'Ctrl+Shift+S',
}),
keybindings: [],
platform: 'linux',
});
const artifact = buildPluginSessionBindingsArtifact({
bindings: result.bindings,
warnings: result.warnings,
numericSelectionTimeoutMs: 2500,
});
const byActionId = new Map(
artifact.bindings.flatMap((binding) =>
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
),
);
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
});
+7
View File
@@ -358,6 +358,13 @@ function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionB
return binding;
}
if (
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple') &&
binding.payload?.count === undefined
) {
return binding;
}
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
}
@@ -103,7 +103,7 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
});
test('subtitle processing can refresh current subtitle without text change', async () => {
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
@@ -119,10 +119,57 @@ test('subtitle processing can refresh current subtitle without text change', asy
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
});
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return await new Promise<SubtitleData | null>((resolve) => {
resolveTokenization = () => resolve({ text, tokens: [] });
});
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
controller.refreshCurrentSubtitle();
controller.refreshCurrentSubtitle('same');
assert.ok(resolveTokenization);
resolveTokenization({ text: 'same', tokens: [] });
await flushMicrotasks();
await flushMicrotasks();
assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
});
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [{ value: tokenizeCalls } as never] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.invalidateTokenizationCache();
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [{ value: 1 } as never] },
{ text: 'same', tokens: [{ value: 2 } as never] },
]);
});
@@ -27,9 +27,10 @@ export function createSubtitleProcessingController(
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
let latestText = '';
let lastEmittedText = '';
let cacheGeneration = 0;
let lastEmittedGeneration = 0;
let processing = false;
let staleDropCount = 0;
let refreshRequested = false;
const tokenizationCache = new Map<string, SubtitleData>();
const now = deps.now ?? (() => Date.now());
@@ -65,19 +66,19 @@ export function createSubtitleProcessingController(
void (async () => {
while (true) {
const text = latestText;
const forceRefresh = refreshRequested;
refreshRequested = false;
const generation = cacheGeneration;
const startedAtMs = now();
if (!text.trim()) {
deps.emitSubtitle({ text, tokens: null });
lastEmittedText = text;
lastEmittedGeneration = generation;
break;
}
let output: SubtitleData = { text, tokens: null };
try {
const cachedTokenized = forceRefresh ? null : getCachedTokenization(text);
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = cachedTokenized;
} else {
@@ -99,8 +100,16 @@ export function createSubtitleProcessingController(
continue;
}
if (generation !== cacheGeneration) {
deps.logDebug?.(
`Dropped stale subtitle tokenization result after cache invalidation; elapsed=${now() - startedAtMs}ms`,
);
continue;
}
deps.emitSubtitle(output);
lastEmittedText = text;
lastEmittedGeneration = generation;
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
@@ -112,7 +121,10 @@ export function createSubtitleProcessingController(
})
.finally(() => {
processing = false;
if (refreshRequested || latestText !== lastEmittedText) {
if (
latestText !== lastEmittedText ||
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
) {
processLatest();
}
});
@@ -133,11 +145,17 @@ export function createSubtitleProcessingController(
if (!latestText.trim()) {
return;
}
refreshRequested = true;
if (
processing ||
(latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration)
) {
return;
}
processLatest();
},
invalidateTokenizationCache: () => {
tokenizationCache.clear();
cacheGeneration += 1;
},
preCacheTokenization: (text: string, data: SubtitleData) => {
setCachedTokenization(text, data);
@@ -150,7 +168,7 @@ export function createSubtitleProcessingController(
latestText = text;
lastEmittedText = text;
refreshRequested = false;
lastEmittedGeneration = cacheGeneration;
return cached;
},
hasCachedSubtitle: (text: string) => {
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldForceX11ElectronBackend } from './electron-backend';
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);
}
}
test('shouldForceX11ElectronBackend forces X11 on Linux except Hyprland/Sway', () => {
withPlatform('linux', () => {
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
assert.equal(shouldForceX11ElectronBackend({ WAYLAND_DISPLAY: 'wayland-0' }), true);
// Even an explicit Wayland hint is overridden to x11 on unsupported compositors.
assert.equal(shouldForceX11ElectronBackend({ ELECTRON_OZONE_PLATFORM_HINT: 'wayland' }), true);
// Hyprland/Sway keep native Wayland (guard reports explicit wayland hints elsewhere).
assert.equal(shouldForceX11ElectronBackend({ HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), false);
assert.equal(shouldForceX11ElectronBackend({ SWAYSOCK: '/tmp/sway.sock' }), false);
});
});
test('shouldForceX11ElectronBackend is false off Linux', () => {
withPlatform('darwin', () => {
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), false);
});
withPlatform('win32', () => {
assert.equal(shouldForceX11ElectronBackend({}), false);
});
});
+21 -10
View File
@@ -1,27 +1,38 @@
import { CliArgs, shouldStartApp } from '../../cli/args';
import { createLogger } from '../../logger';
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
const logger = createLogger('core:electron-backend');
function getElectronOzonePlatformHint(): string | null {
const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
if (hint) return hint;
const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase();
const ozone = env.OZONE_PLATFORM?.trim().toLowerCase();
if (ozone) return ozone;
return null;
}
function shouldPreferWaylandBackend(): boolean {
return Boolean(process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK);
/**
* Should the Electron app be pinned to the X11/XWayland ozone backend? True on Linux
* unless we're on a natively-supported Wayland compositor (Hyprland/Sway) or the user
* explicitly opted into the (unsupported) Wayland backend — which is reported by
* {@link enforceUnsupportedWaylandMode} instead.
*
* The overlay relies on `setAlwaysOnTop`/`moveTop` to stay above mpv; those are no-ops
* under a native Wayland surface, so XWayland is required for parity with Win/macOS. An
* explicit `ELECTRON_OZONE_PLATFORM_HINT=wayland` is still overridden to x11 here (the
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
* {@link enforceUnsupportedWaylandMode} can report it.
*/
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') return false;
return !isSupportedWaylandCompositor(env);
}
export function forceX11Backend(args: CliArgs): void {
if (process.platform !== 'linux') return;
if (!shouldStartApp(args)) return;
if (shouldPreferWaylandBackend()) return;
const hint = getElectronOzonePlatformHint();
if (hint === 'x11') return;
if (!shouldForceX11ElectronBackend()) return;
if (getElectronOzonePlatformHint() === 'x11') return;
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
process.env.OZONE_PLATFORM = 'x11';
+5 -1
View File
@@ -1,5 +1,9 @@
export { generateDefaultConfigFile } from './config-gen';
export { enforceUnsupportedWaylandMode, forceX11Backend } from './electron-backend';
export {
enforceUnsupportedWaylandMode,
forceX11Backend,
shouldForceX11ElectronBackend,
} from './electron-backend';
export { resolveKeybindings } from './keybindings';
export { resolveConfiguredShortcuts } from './shortcut-config';
export { showDesktopNotification } from './notification';
+652 -32
View File
File diff suppressed because it is too large Load Diff
+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);
}
}
+10
View File
@@ -242,6 +242,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
getSubtitleSidebarOpen: (): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getPlaybackPaused: (): Promise<boolean | null> =>
@@ -259,6 +261,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options);
},
reportOverlayInteractive: (interactive: boolean) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayInteractive, interactive);
},
openYomitanSettings: () => {
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
},
@@ -350,6 +356,10 @@ const electronAPI: ElectronAPI = {
getCurrentSecondarySub: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSecondarySub),
focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise<void>,
activatePlaybackWindowForOverlayInteraction: () =>
ipcRenderer.invoke(
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
) as Promise<boolean>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
onSubsyncManualOpen: onSubsyncManualOpenEvent,
+75
View File
@@ -103,12 +103,14 @@ function installKeyboardTestGlobals() {
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
const previousElement = (globalThis as { Element?: unknown }).Element;
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
@@ -179,6 +181,14 @@ function installKeyboardTestGlobals() {
}
}
class TestElement {
tagName = 'DIV';
closest(_selector: string): unknown {
return null;
}
}
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: TestCustomEvent,
@@ -189,6 +199,11 @@ function installKeyboardTestGlobals() {
value: TestMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: TestElement,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -242,6 +257,10 @@ function installKeyboardTestGlobals() {
focusMainWindowCalls += 1;
return Promise.resolve();
},
activatePlaybackWindowForOverlayInteraction: async () => {
interactionActivations.push('activate-playback-window');
return true;
},
notifyOverlayModalOpened: (modal: string) => {
openedModalNotifications.push(modal);
},
@@ -303,6 +322,18 @@ function installKeyboardTestGlobals() {
}
}
function dispatchDocumentMouseDown(event: { button: number; target?: unknown }): void {
const listeners = documentListeners.get('mousedown') ?? [];
const mouseEvent = {
button: event.button,
target: event.target ?? null,
preventDefault: () => {},
};
for (const listener of listeners) {
listener(mouseEvent);
}
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
@@ -335,6 +366,10 @@ function installKeyboardTestGlobals() {
configurable: true,
value: previousMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: previousElement,
});
}
const overlay = {
@@ -348,10 +383,12 @@ function installKeyboardTestGlobals() {
mpvCommands,
sessionActions,
overlay,
interactionActivations,
overlayFocusCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
windowFocusCalls: () => windowFocusCalls,
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
@@ -369,6 +406,11 @@ function installKeyboardTestGlobals() {
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
createInteractiveTarget: () => {
const target = new TestElement();
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
return target;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
@@ -565,6 +607,39 @@ test('mpv input forwarding waits for session bindings before resolving setup', a
}
});
test('right-clicking non-interactive overlay content raises playback window before toggling pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2 });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, ['activate-playback-window']);
assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
test('right-clicking interactive overlay controls does not raise playback window or toggle pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const interactiveTarget = testGlobals.createInteractiveTarget();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2, target: interactiveTarget });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, []);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
let calls = 0;
+6 -1
View File
@@ -1218,7 +1218,12 @@ export function createKeyboardHandlers(
document.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 2 && !isInteractiveTarget(e.target)) {
e.preventDefault();
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
void window.electronAPI
.activatePlaybackWindowForOverlayInteraction()
.catch(() => false)
.finally(() => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
});
}
});
@@ -236,6 +236,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalNotifications: string[] = [];
const snapshot: SubtitleSidebarSnapshot = {
cues: [
@@ -280,6 +281,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
notifyOverlayModalOpened: (modal: string) => {
modalNotifications.push(`open:${modal}`);
},
notifyOverlayModalClosed: (modal: string) => {
modalNotifications.push(`close:${modal}`);
},
} as unknown as ElectronAPI,
},
});
@@ -329,9 +336,13 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
},
state,
};
const visibilityChanges: boolean[] = [];
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
onVisibilityChanged: (visible) => {
visibilityChanges.push(visible);
},
});
await modal.openSubtitleSidebarModal();
@@ -345,9 +356,14 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
assert.equal(contentStyleValues.get('font-size'), '22px');
assert.equal(contentStyle.color, '#ffffff');
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
assert.deepEqual(visibilityChanges, [true]);
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
modal.closeSubtitleSidebarModal();
assert.deepEqual(visibilityChanges, [true, false]);
assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
@@ -760,6 +776,104 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
}
});
test('subtitle sidebar auto-open restores previously open sidebar after renderer replacement', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const modalClassList = createClassList(['hidden']);
const cueList = createListStub();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: modalClassList,
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
shouldRestoreOpenOnStartup: async () => true,
});
await modal.autoOpenSubtitleSidebarOnStartup();
assert.equal(state.subtitleSidebarModalOpen, true);
assert.equal(modalClassList.contains('hidden'), false);
assert.equal(cueList.children.length, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar refresh closes and clears state when config becomes disabled', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
+8 -1
View File
@@ -196,6 +196,8 @@ export function createSubtitleSidebarModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
onVisibilityChanged?: (visible: boolean) => void;
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
},
) {
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
@@ -648,13 +650,16 @@ export function createSubtitleSidebarModal(
startSnapshotPolling();
syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
options.onVisibilityChanged?.(true);
}
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
const snapshot = await refreshSnapshot();
const shouldRestoreOpen = (await options.shouldRestoreOpenOnStartup?.()) === true;
if (
!snapshot.config.enabled ||
!snapshot.config.autoOpen ||
(!snapshot.config.autoOpen && !shouldRestoreOpen) ||
ctx.state.subtitleSidebarModalOpen
) {
return;
@@ -677,6 +682,8 @@ export function createSubtitleSidebarModal(
ctx.dom.overlay.classList.remove('interactive');
}
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalClosed?.('subtitle-sidebar');
options.onVisibilityChanged?.(false);
}
async function toggleSubtitleSidebarModal(): Promise<void> {
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
function makeElement(textContent: string, rect: DOMRect): HTMLElement {
return {
textContent,
getBoundingClientRect: () => rect,
} as unknown as HTMLElement;
}
test('overlay measurement reports primary and secondary subtitle bars as separate interactive rects', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
dom: {
subtitleRoot: makeElement('primary', {
left: 810,
top: 910,
width: 300,
height: 48,
} as DOMRect),
subtitleContainer: makeElement('primary', {
left: 760,
top: 890,
width: 400,
height: 92,
} as DOMRect),
secondarySubRoot: makeElement('English', {
left: 850,
top: 50,
width: 220,
height: 34,
} as DOMRect),
secondarySubContainer: makeElement('English', {
left: 700,
top: 40,
width: 520,
height: 70,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 942 },
interactiveRects: [
{ x: 760, y: 890, width: 400, height: 92 },
{ x: 700, y: 40, width: 520, height: 70 },
],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay measurement includes open subtitle sidebar bounds as an interactive rect', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
state: { subtitleSidebarModalOpen: true },
dom: {
subtitleRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleSidebarContent: makeElement('sidebar', {
left: 1500,
top: 60,
width: 380,
height: 900,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 1500, y: 60, width: 380, height: 900 },
interactiveRects: [{ x: 1500, y: 60, width: 380, height: 900 }],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
+28 -8
View File
@@ -47,25 +47,43 @@ function hasVisibleTextContent(element: HTMLElement): boolean {
return Boolean(element.textContent && element.textContent.trim().length > 0);
}
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
function hasArea(rect: OverlayContentRect): boolean {
return rect.width > 0 && rect.height > 0;
}
function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
const rects: OverlayContentRect[] = [];
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
if (subtitleRect) {
combinedRect = subtitleRect;
const subtitleRect = toMeasuredRect(ctx.dom.subtitleContainer.getBoundingClientRect());
if (subtitleRect && hasArea(subtitleRect)) {
rects.push(subtitleRect);
}
}
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
if (secondaryHasContent) {
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
if (secondaryRect) {
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
if (secondaryRect && hasArea(secondaryRect)) {
rects.push(secondaryRect);
}
}
if (ctx.state?.subtitleSidebarModalOpen) {
const sidebarRect = toMeasuredRect(ctx.dom.subtitleSidebarContent.getBoundingClientRect());
if (sidebarRect && hasArea(sidebarRect)) {
rects.push(sidebarRect);
}
}
return rects;
}
function collectContentRect(rects: OverlayContentRect[]): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
for (const rect of rects) {
combinedRect = combinedRect ? unionRects(combinedRect, rect) : rect;
}
if (!combinedRect) {
return null;
}
@@ -86,6 +104,7 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
return;
}
const interactiveRects = collectInteractiveRects(ctx);
const measurement: OverlayContentMeasurement = {
layer: ctx.platform.overlayLayer,
measuredAtMs: Date.now(),
@@ -94,7 +113,8 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
height: window.innerHeight,
},
// Explicit null rect signals "no content yet", and main should use fallback bounds.
contentRect: collectContentRect(ctx),
contentRect: collectContentRect(interactiveRects),
interactiveRects,
};
window.electronAPI.reportOverlayContentBounds(measurement);
+134 -37
View File
@@ -15,17 +15,30 @@ function createClassList() {
};
}
function replaceGlobalProperty(key: 'window' | 'document', value: unknown): () => void {
const original = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
return () => {
if (original) {
Object.defineProperty(globalThis, key, original);
return;
}
delete (globalThis as Record<string, unknown>)[key];
};
}
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
@@ -58,21 +71,18 @@ test('idle visible overlay starts click-through on platforms that toggle mouse i
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
restoreWindow();
}
});
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
@@ -105,36 +115,32 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
restoreWindow();
}
});
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
},
document: {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
});
const restoreDocument = replaceGlobalProperty('document', {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
});
try {
@@ -165,6 +171,97 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
restoreDocument();
restoreWindow();
}
});
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: true,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(interactiveHints, [false]);
} finally {
restoreWindow();
}
});
test('Linux modal state reports whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: true,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(interactiveHints, [true]);
} finally {
restoreWindow();
}
});
+13 -5
View File
@@ -26,18 +26,26 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
}
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldKeepWindowInteractive =
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
isYomitanPopupInteractionActive(ctx.state) ||
isBlockingOverlayModalOpen(ctx.state);
ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive;
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
? shouldKeepWindowInteractive
: shouldStayInteractive;
if (shouldStayInteractive) {
if (shouldMarkOverlayInteractive) {
ctx.dom.overlay.classList.add('interactive');
} else {
ctx.dom.overlay.classList.remove('interactive');
}
if (!ctx.platform?.shouldToggleMouseIgnore) {
// On Linux the main process owns window passthrough via a cursor poll (Electron can't
// forward mouse-move through a click-through window on X11). Report the interactive hint
// only for popups/modals that sit off measured hit rects; subtitles/sidebar use the poll.
if (ctx.platform?.isLinuxPlatform) {
window.electronAPI.reportOverlayInteractive?.(shouldKeepWindowInteractive);
}
return;
}
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const rendererSource = fs.readFileSync(
path.join(process.cwd(), 'src/renderer/renderer.ts'),
'utf8',
);
function indexOfRequired(pattern: string): number {
const index = rendererSource.indexOf(pattern);
assert.notEqual(index, -1, `Expected renderer.ts to contain ${pattern}`);
return index;
}
test('renderer applies subtitle style and position before first subtitle paint', () => {
const styleIndex = indexOfRequired(
'const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();',
);
const positionIndex = indexOfRequired(
"await window.electronAPI.getSubtitlePosition(),\n 'startup',",
);
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
const currentSubtitleIndex = indexOfRequired(
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
);
assert.ok(styleIndex < listenerIndex);
assert.ok(positionIndex < listenerIndex);
assert.ok(styleIndex < currentSubtitleIndex);
assert.ok(positionIndex < currentSubtitleIndex);
});
test('renderer renders initial subtitle snapshot before subscribing to live subtitle updates', () => {
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
const currentSubtitleIndex = indexOfRequired(
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
);
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
assert.ok(currentSubtitleIndex < initialRenderIndex);
assert.ok(initialRenderIndex < listenerIndex);
});
test('renderer reports subtitle bounds immediately after initial subtitle layout', () => {
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
const initialLayoutIndex = indexOfRequired(
'subtitleRenderer.renderSubtitle(initialSubtitle);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
);
const immediateMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
assert.equal(initialRenderIndex, initialLayoutIndex);
assert.ok(initialLayoutIndex < immediateMeasurementIndex);
assert.ok(immediateMeasurementIndex < listenerIndex);
});
test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
const liveLayoutIndex = indexOfRequired(
'subtitleRenderer.renderSubtitle(data);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
);
const immediateMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
const sidebarUpdateIndex = indexOfRequired('subtitleSidebarModal.handleSubtitleUpdated(data);');
assert.equal(liveRenderIndex, liveLayoutIndex);
assert.ok(liveLayoutIndex < immediateMeasurementIndex);
assert.ok(immediateMeasurementIndex < sidebarUpdateIndex);
});
test('renderer restores subtitle sidebar open state only on visible overlay layer', () => {
const sidebarRestoreIndex = indexOfRequired(
"ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen())",
);
const sidebarModalIndex = indexOfRequired('const subtitleSidebarModal = createSubtitleSidebarModal');
assert.ok(sidebarModalIndex < sidebarRestoreIndex);
});
+32 -23
View File
@@ -142,6 +142,11 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
shouldRestoreOpenOnStartup: async () =>
ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen()),
onVisibilityChanged: () => {
measurementReporter.emitNow();
},
});
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
@@ -596,15 +601,16 @@ async function init(): Promise<void> {
syncOverlayMouseIgnoreState(ctx);
}
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
await keyboardHandlers.setupMpvInputForwarding();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
@@ -618,8 +624,6 @@ async function init(): Promise<void> {
});
});
await keyboardHandlers.setupMpvInputForwarding();
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
@@ -629,7 +633,20 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
positioning.applyYPercent(positioning.getCurrentYPercent());
measurementReporter.emitNow();
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
positioning.applyYPercent(positioning.getCurrentYPercent());
measurementReporter.emitNow();
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
window.electronAPI.onSecondarySub((text: string) => {
runGuarded('secondary-subtitle:update', () => {
@@ -713,18 +730,9 @@ async function init(): Promise<void> {
}
startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
measurementReporter.emitNow();
}
@@ -775,7 +783,8 @@ function setupDragDropToMpvQueue(): void {
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
const appendDroppedVideos = event.shiftKey;
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, appendDroppedVideos);
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
for (const command of loadCommands) {
window.electronAPI.sendMpvCommand(command);
@@ -785,7 +794,7 @@ function setupDragDropToMpvQueue(): void {
}
const osdParts: string[] = [];
if (loadCommands.length > 0) {
const action = event.shiftKey ? 'Queued' : 'Loaded';
const action = appendDroppedVideos ? 'Queued' : 'Loaded';
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
}
if (subtitleCommands.length > 0) {
+5
View File
@@ -1218,6 +1218,11 @@ body.settings-modal-open #secondarySubContainer {
pointer-events: none !important;
}
body.settings-modal-open .subtitle-sidebar-modal {
display: none !important;
pointer-events: none !important;
}
.secondary-sub-hidden {
display: none !important;
}
+133
View File
@@ -57,6 +57,7 @@ class FakeElement {
dataset: Record<string, string> = {};
style = new FakeStyleDeclaration();
className = '';
replaceChildrenCalls = 0;
private ownTextContent = '';
constructor(public tagName: string) {}
@@ -97,6 +98,7 @@ class FakeElement {
}
replaceChildren(): void {
this.replaceChildrenCalls += 1;
this.childNodes = [];
this.ownTextContent = '';
}
@@ -347,6 +349,130 @@ test('renderSubtitle skips character image when name-match rendering is disabled
}
});
test('renderSubtitle skips identical primary subtitle DOM replacement', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({ text: '字幕', tokens: null });
renderer.renderSubtitle({ text: '字幕', tokens: null });
renderer.renderSubtitle({ text: '字幕2', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.equal(subtitleRoot.textContent, '字幕2');
} finally {
restoreDocument();
}
});
test('renderSubtitle keeps tokenized subtitle when stale plain payload repeats same text', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
});
renderer.renderSubtitle({ text: 'アクア', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 1);
assert.equal(collectWordNodes(subtitleRoot).length, 1);
assert.equal(subtitleRoot.textContent, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle accepts repeated plain payload after style invalidates tokenized render', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
});
renderer.applySubtitleStyle({ fontColor: '#fff' } as never);
renderer.renderSubtitle({ text: 'アクア', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.equal(collectWordNodes(subtitleRoot).length, 0);
assert.equal(subtitleRoot.textContent, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle re-renders identical text after style changes affect token output', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: {
...createRendererState(),
nameMatchEnabled: false,
},
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const subtitle = {
text: 'アクア',
tokens: [
{
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
isNameMatch: true,
} as MergedToken,
],
};
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle(subtitle);
renderer.applySubtitleStyle({ nameMatchEnabled: true } as never);
renderer.renderSubtitle(subtitle);
const [word] = collectWordNodes(subtitleRoot);
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.ok(word?.className.includes('word-name-match'));
} finally {
restoreDocument();
}
});
test('renderer content security policy allows data URL character images', () => {
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
@@ -1231,6 +1357,13 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
const sidebarSettingsModalBlock = extractClassBlock(
cssText,
'body.settings-modal-open .subtitle-sidebar-modal',
);
assert.match(sidebarSettingsModalBlock, /display:\s*none !important;/);
assert.match(sidebarSettingsModalBlock, /pointer-events:\s*none !important;/);
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
+48 -3
View File
@@ -653,9 +653,32 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
}
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.replaceChildren();
let lastPrimarySubtitleRenderKey: string | null = null;
let lastPrimarySubtitleNormalizedText: string | null = null;
let lastPrimarySubtitleRenderedTokenized = false;
function getPrimarySubtitleRenderKey(
text: string,
normalized: string,
tokens: MergedToken[] | null,
): string {
if (!shouldRenderTokenizedSubtitle(tokens?.length ?? 0) || !tokens) {
return JSON.stringify({
mode: 'plain',
text: normalized,
});
}
return JSON.stringify({
mode: 'tokens',
text,
tokens,
settings: getTokenRenderSettings(),
preserveSubtitleLineBreaks: ctx.state.preserveSubtitleLineBreaks,
});
}
function renderSubtitle(data: SubtitleData | string): void {
let text: string;
let tokens: MergedToken[] | null;
@@ -669,9 +692,30 @@ export function createSubtitleRenderer(ctx: RendererContext) {
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
const hasRenderableTokens =
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
if (
lastPrimarySubtitleRenderKey !== null &&
!hasRenderableTokens &&
lastPrimarySubtitleRenderedTokenized &&
normalized === lastPrimarySubtitleNormalizedText
) {
return;
}
const renderKey = getPrimarySubtitleRenderKey(text, normalized, tokens);
if (renderKey === lastPrimarySubtitleRenderKey) {
return;
}
lastPrimarySubtitleRenderKey = renderKey;
lastPrimarySubtitleNormalizedText = normalized;
lastPrimarySubtitleRenderedTokenized = hasRenderableTokens;
ctx.dom.subtitleRoot.replaceChildren();
if (!text) return;
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
renderWithTokens(
ctx.dom.subtitleRoot,
@@ -753,6 +797,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void {
if (!style) return;
lastPrimarySubtitleRenderKey = null;
const styleDeclarations = style as Record<string, unknown>;
applyInlineStyleDeclarations(ctx.dom.subtitleRoot, styleDeclarations, CONTAINER_STYLE_KEYS);
+3
View File
@@ -39,6 +39,7 @@ export const IPC_CHANNELS = {
refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
reportOverlayContentBounds: 'overlay-content-bounds:report',
reportOverlayInteractive: 'overlay-interactive:report',
overlayModalOpened: 'overlay:modal-opened',
toggleStatsOverlay: 'stats:toggle-overlay',
markActiveVideoWatched: 'immersion:mark-active-video-watched',
@@ -50,6 +51,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style',
@@ -64,6 +66,7 @@ export const IPC_CHANNELS = {
getCurrentSecondarySub: 'get-current-secondary-sub',
youtubePickerResolve: 'youtube:picker-resolve',
focusMainWindow: 'focus-main-window',
activatePlaybackWindowForOverlayInteraction: 'overlay:activate-playback-window',
runSubsyncManual: 'subsync:run-manual',
getAnkiConnectStatus: 'get-anki-connect-status',
getRuntimeOptions: 'runtime-options:get',
+107
View File
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
isSupportedWaylandCompositor,
shouldForceX11MpvBackend,
shouldForceX11WaylandSession,
} from './mpv-x11-backend';
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);
}
}
const KDE_WAYLAND = {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma',
};
test('isSupportedWaylandCompositor detects Hyprland and Sway via env or xdg desktop', () => {
assert.equal(isSupportedWaylandCompositor({ HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), true);
assert.equal(isSupportedWaylandCompositor({ SWAYSOCK: '/tmp/sway.sock' }), true);
assert.equal(isSupportedWaylandCompositor({ XDG_CURRENT_DESKTOP: 'Hyprland' }), true);
assert.equal(isSupportedWaylandCompositor({ XDG_SESSION_DESKTOP: 'sway' }), true);
assert.equal(isSupportedWaylandCompositor(KDE_WAYLAND), false);
});
test('shouldForceX11WaylandSession forces X11 for unsupported Wayland sessions only', () => {
withPlatform('linux', () => {
assert.equal(shouldForceX11WaylandSession(KDE_WAYLAND), true);
// GNOME Wayland (also unsupported) → forced.
assert.equal(
shouldForceX11WaylandSession({
DISPLAY: ':0',
WAYLAND_DISPLAY: 'wayland-0',
XDG_CURRENT_DESKTOP: 'GNOME',
}),
true,
);
// Hyprland keeps native Wayland.
assert.equal(
shouldForceX11WaylandSession({ ...KDE_WAYLAND, HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
// No X11 display to fall back to.
assert.equal(shouldForceX11WaylandSession({ WAYLAND_DISPLAY: 'wayland-0' }), false);
// Pure X11 session (no Wayland) → nothing to force.
assert.equal(shouldForceX11WaylandSession({ DISPLAY: ':0', XDG_SESSION_TYPE: 'x11' }), false);
});
});
test('shouldForceX11WaylandSession is false off Linux', () => {
withPlatform('darwin', () => {
assert.equal(shouldForceX11WaylandSession(KDE_WAYLAND), false);
});
withPlatform('win32', () => {
assert.equal(shouldForceX11WaylandSession(KDE_WAYLAND), false);
});
});
test('shouldForceX11MpvBackend honors explicit x11 and auto modes', () => {
withPlatform('linux', () => {
// Explicit x11 forces even without Wayland.
assert.equal(shouldForceX11MpvBackend('x11', { DISPLAY: ':0' }), true);
// Auto defers to the session check.
assert.equal(shouldForceX11MpvBackend('auto', KDE_WAYLAND), true);
assert.equal(
shouldForceX11MpvBackend('auto', { ...KDE_WAYLAND, SWAYSOCK: '/tmp/sway.sock' }),
false,
);
// No display at all.
assert.equal(shouldForceX11MpvBackend('x11', {}), false);
});
});
test('applyX11EnvOverrides strips Wayland hints and pins session type to x11', () => {
const env = {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
SWAYSOCK: '/tmp/sway.sock',
};
const result = applyX11EnvOverrides(env);
assert.equal(result, env); // mutates in place
assert.equal(result.DISPLAY, ':1');
assert.equal(result.WAYLAND_DISPLAY, undefined);
assert.equal(result.HYPRLAND_INSTANCE_SIGNATURE, undefined);
assert.equal(result.SWAYSOCK, undefined);
assert.equal(result.XDG_SESSION_TYPE, 'x11');
});
test('MPV_X11_BACKEND_ARGS pins the GPU stack to X11', () => {
assert.deepEqual(
[...MPV_X11_BACKEND_ARGS],
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
);
});
+93
View File
@@ -0,0 +1,93 @@
/*
Shared XWayland/X11 backend forcing for mpv and the Electron app.
On Wayland sessions the SubMiner overlay can only be reliably kept above mpv when
BOTH processes run under XWayland: the Wayland protocol forbids clients from
controlling window stacking, so Electron's `setAlwaysOnTop`/`moveTop` become
no-ops under a native Wayland surface. Hyprland and Sway are the exception they
are supported natively via compositor-specific window placement so all forcing
here is gated to "Linux + Wayland session + NOT Hyprland/Sway".
This module is shared between the `launcher/` bundle and the Electron `src/` build
so the gate and the mpv backend args stay in one place.
*/
/** mpv args that pin the GPU/windowing stack to X11/XWayland (libGL via EGL on X11). */
export const MPV_X11_BACKEND_ARGS = [
'--vo=gpu',
'--gpu-api=opengl',
'--gpu-context=x11egl,x11',
] as const;
export type LinuxDesktopEnv = {
xdgCurrentDesktop: string;
xdgSessionDesktop: string;
hasWayland: boolean;
};
export function getLinuxDesktopEnv(env: NodeJS.ProcessEnv = process.env): LinuxDesktopEnv {
const xdgCurrentDesktop = (env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (env.XDG_SESSION_TYPE || '').toLowerCase();
return {
xdgCurrentDesktop,
xdgSessionDesktop,
hasWayland: Boolean(env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland',
};
}
/**
* Compositors that SubMiner supports natively on Wayland (no XWayland forcing).
* Detected via their socket env vars or the XDG desktop identifiers.
*/
export function isSupportedWaylandCompositor(env: NodeJS.ProcessEnv = process.env): boolean {
const desktop = getLinuxDesktopEnv(env);
return (
Boolean(env.HYPRLAND_INSTANCE_SIGNATURE || env.SWAYSOCK) ||
desktop.xdgCurrentDesktop.includes('hyprland') ||
desktop.xdgCurrentDesktop.includes('sway') ||
desktop.xdgSessionDesktop.includes('hyprland') ||
desktop.xdgSessionDesktop.includes('sway')
);
}
/**
* Should this Linux session be pushed onto XWayland/X11? True for a Wayland session
* that is not one of the natively-supported compositors and has an X11 display
* available for the fallback. This is the "auto" decision shared by the Electron app
* and SubMiner-managed mpv launches.
*/
export function shouldForceX11WaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') return false;
if (!env.DISPLAY?.trim()) return false;
if (!getLinuxDesktopEnv(env).hasWayland) return false;
return !isSupportedWaylandCompositor(env);
}
/**
* Launcher-facing decision that also honors an explicit `--backend` choice:
* - `x11` forces the X11 stack whenever an X11 display exists,
* - `auto` defers to {@link shouldForceX11WaylandSession}.
*/
export function shouldForceX11MpvBackend(
backend: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
return false;
}
if (backend === 'x11') return true;
return backend === 'auto' && shouldForceX11WaylandSession(env);
}
/**
* Strip Wayland/compositor hints and pin the session type to X11 on the given env
* object (mutates in place and returns it) so a child mpv process picks XWayland.
*/
export function applyX11EnvOverrides(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
delete env.WAYLAND_DISPLAY;
delete env.HYPRLAND_INSTANCE_SIGNATURE;
delete env.SWAYSOCK;
env.XDG_SESSION_TYPE = 'x11';
return env;
}
+4
View File
@@ -329,6 +329,7 @@ export interface OverlayContentMeasurement {
height: number;
};
contentRect: OverlayContentRect | null;
interactiveRects?: OverlayContentRect[];
}
export interface MecabStatus {
@@ -410,9 +411,11 @@ export interface ElectronAPI {
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarOpen: () => Promise<boolean>;
getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
reportOverlayInteractive: (interactive: boolean) => void;
openYomitanSettings: () => void;
recordYomitanLookup: (context?: SubtitleMiningContext | null) => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
@@ -449,6 +452,7 @@ export interface ElectronAPI {
getSecondarySubMode: () => Promise<SecondarySubMode>;
getCurrentSecondarySub: () => Promise<string>;
focusMainWindow: () => Promise<void>;
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
+12
View File
@@ -70,6 +70,18 @@ export abstract class BaseWindowTracker {
return false;
}
getTargetWindowMediaSourceId(): string | null {
return null;
}
getTargetWindowNativeId(): string | null {
return null;
}
raiseTargetWindow(): Promise<boolean> {
return Promise.resolve(false);
}
protected updateTargetWindowFocused(focused: boolean): void {
if (this.targetWindowFocused === focused) {
return;
+222 -1
View File
@@ -1,6 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker';
import {
normalizeX11WindowId,
parseX11RootActiveWindowId,
parseX11WindowGeometry,
parseX11WindowPid,
X11WindowTracker,
} from './x11-tracker';
import { parseMacOSHelperOutput } from './macos-tracker';
test('parseX11WindowGeometry parses xwininfo output', () => {
@@ -38,6 +44,19 @@ test('parseX11WindowPid parses xprop output', () => {
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null);
});
test('normalizeX11WindowId normalizes decimal and hex ids', () => {
assert.equal(normalizeX11WindowId('123\n'), '123');
assert.equal(normalizeX11WindowId('0x7b'), '123');
assert.equal(normalizeX11WindowId(''), null);
assert.equal(normalizeX11WindowId('nope'), null);
});
test('parseX11RootActiveWindowId parses root _NET_ACTIVE_WINDOW output', () => {
assert.equal(parseX11RootActiveWindowId('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x7b'), '123');
assert.equal(parseX11RootActiveWindowId('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0'), '0');
assert.equal(parseX11RootActiveWindowId('_NET_ACTIVE_WINDOW: not found.'), null);
});
test('X11WindowTracker searches only visible mpv windows', async () => {
const commands: Array<{ command: string; args: string[] }> = [];
const tracker = new X11WindowTracker(undefined, async (command, args) => {
@@ -63,6 +82,147 @@ Height: 360`;
});
});
test('X11WindowTracker updates target focus from active X11 window', async () => {
let activeWindowId = '999';
const tracker = new X11WindowTracker(undefined, async (command, args) => {
if (command === 'xdotool' && args[0] === 'search') {
return '123';
}
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
return `_NET_ACTIVE_WINDOW(WINDOW): window id # ${activeWindowId}`;
}
if (command === 'xwininfo') {
return `Absolute upper-left X: 0
Absolute upper-left Y: 0
Width: 640
Height: 360`;
}
return '';
});
const focusStates: boolean[] = [];
tracker.onWindowFocusChange = (focused) => {
focusStates.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), false);
assert.deepEqual(focusStates, []);
activeWindowId = '123';
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(focusStates, [true]);
});
test('X11WindowTracker falls back to xdotool active window when root active window is unavailable', async () => {
const commands: Array<{ command: string; args: string[] }> = [];
const tracker = new X11WindowTracker(undefined, async (command, args) => {
commands.push({ command, args });
if (command === 'xdotool' && args[0] === 'search') {
return '123';
}
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
throw new Error('missing root active window');
}
if (command === 'xdotool' && args[0] === 'getactivewindow') {
return '999';
}
if (command === 'xwininfo') {
return `Absolute upper-left X: 0
Absolute upper-left Y: 0
Width: 640
Height: 360`;
}
return '';
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), false);
assert.ok(
commands.some(
(call) => call.command === 'xprop' && call.args.join(' ') === '-root _NET_ACTIVE_WINDOW',
),
);
assert.ok(
commands.some((call) => call.command === 'xdotool' && call.args[0] === 'getactivewindow'),
);
});
test('X11WindowTracker treats a different root active X11 window as mpv unfocused', async () => {
const socketPath = '/tmp/subminer-mpv.sock';
const tracker = new X11WindowTracker(socketPath, async (command, args) => {
if (command === 'xdotool' && args[0] === 'search') {
return '123';
}
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
return '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3e7';
}
if (command === 'xprop' && args.join(' ') === '-id 123 _NET_WM_PID') {
return '_NET_WM_PID(CARDINAL) = 4242';
}
if (command === 'xprop' && args.join(' ') === '-id 999 _NET_WM_PID') {
return '_NET_WM_PID(CARDINAL) = 9999';
}
if (command === 'ps') {
return `mpv --input-ipc-server=${socketPath}`;
}
if (command === 'xwininfo') {
return `Absolute upper-left X: 0
Absolute upper-left Y: 0
Width: 640
Height: 360`;
}
return '';
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), false);
assert.equal(tracker.getTargetWindowMediaSourceId(), 'window:123:0');
assert.equal(tracker.getTargetWindowNativeId(), '123');
});
test('X11WindowTracker treats active X11 windows with matching PID as focused', async () => {
const socketPath = '/tmp/subminer-mpv.sock';
const tracker = new X11WindowTracker(socketPath, async (command, args) => {
if (command === 'xdotool' && args[0] === 'search') {
return '123';
}
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
return '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3e7';
}
if (command === 'xprop' && args.join(' ') === '-id 123 _NET_WM_PID') {
return '_NET_WM_PID(CARDINAL) = 4242';
}
if (command === 'xprop' && args.join(' ') === '-id 999 _NET_WM_PID') {
return '_NET_WM_PID(CARDINAL) = 4242';
}
if (command === 'ps') {
return `mpv --input-ipc-server=${socketPath}`;
}
if (command === 'xwininfo') {
return `Absolute upper-left X: 0
Absolute upper-left Y: 0
Width: 640
Height: 360`;
}
return '';
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), true);
});
test('X11WindowTracker skips overlapping polls while one command is in flight', async () => {
let commandCalls = 0;
let release: (() => void) | undefined;
@@ -94,6 +254,67 @@ Height: 360`;
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('X11WindowTracker activates and raises the tracked mpv window without changing the target', async () => {
const commands: Array<{ command: string; args: string[] }> = [];
const tracker = new X11WindowTracker(undefined, async (command, args) => {
commands.push({ command, args });
if (command === 'xdotool' && args[0] === 'search') {
return '123';
}
if (command === 'xdotool' && args[0] === 'windowactivate') {
return '';
}
if (command === 'xdotool' && args[0] === 'windowraise') {
return '';
}
if (command === 'xwininfo') {
return `Absolute upper-left X: 0
Absolute upper-left Y: 0
Width: 640
Height: 360`;
}
return '';
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
const raised = await tracker.raiseTargetWindow();
assert.equal(raised, true);
assert.ok(
commands.some(
(call) => call.command === 'xdotool' && call.args.join(' ') === 'windowactivate 123',
),
);
assert.ok(
commands.some(
(call) => call.command === 'xdotool' && call.args.join(' ') === 'windowraise 123',
),
);
});
test('X11WindowTracker raises the same target id captured before activation', async () => {
const commands: Array<{ command: string; args: string[] }> = [];
const tracker = new X11WindowTracker(undefined, async (command, args) => {
commands.push({ command, args });
if (command === 'xdotool' && args[0] === 'windowactivate') {
(tracker as unknown as { targetWindowId: string | null }).targetWindowId = '456';
return '';
}
return '';
});
(tracker as unknown as { targetWindowId: string | null }).targetWindowId = '123';
const raised = await tracker.raiseTargetWindow();
assert.equal(raised, true);
assert.deepEqual(
commands.filter((call) => call.command === 'xdotool').map((call) => call.args.join(' ')),
['windowactivate 123', 'windowraise 123'],
);
});
test('parseMacOSHelperOutput parses geometry and focused state', () => {
assert.deepEqual(parseMacOSHelperOutput('120,240,1280,720,1'), {
geometry: {
+115 -9
View File
@@ -63,10 +63,32 @@ export function parseX11WindowPid(raw: string): number | null {
return Number.isInteger(pid) ? pid : null;
}
export function normalizeX11WindowId(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
try {
return BigInt(trimmed).toString();
} catch {
return null;
}
}
export function parseX11RootActiveWindowId(raw: string): string | null {
const match = raw.match(/window id #\s*(\S+)/i);
if (!match) {
return null;
}
return normalizeX11WindowId(match[1]!);
}
export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private targetWindowId: string | null = null;
private targetWindowPid: number | null = null;
private pollInFlight = false;
private currentPollIntervalMs = 750;
private readonly stablePollIntervalMs = 250;
@@ -89,6 +111,38 @@ export class X11WindowTracker extends BaseWindowTracker {
}
}
override getTargetWindowMediaSourceId(): string | null {
const normalizedWindowId = this.targetWindowId
? normalizeX11WindowId(this.targetWindowId)
: null;
return normalizedWindowId ? `window:${normalizedWindowId}:0` : null;
}
override getTargetWindowNativeId(): string | null {
return this.targetWindowId ? normalizeX11WindowId(this.targetWindowId) : null;
}
override async raiseTargetWindow(): Promise<boolean> {
const targetWindowId = this.targetWindowId;
if (!targetWindowId) {
return false;
}
let raised = false;
try {
await this.runCommand('xdotool', ['windowactivate', targetWindowId]);
raised = true;
} catch {
// Some WMs reject activation but accept a plain restack below.
}
try {
await this.runCommand('xdotool', ['windowraise', targetWindowId]);
raised = true;
} catch {
// Keep any successful activation result.
}
return raised;
}
private resetPollInterval(intervalMs: number): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
@@ -132,9 +186,14 @@ export class X11WindowTracker extends BaseWindowTracker {
const windowId = await this.findTargetWindowId(windowIdList);
if (!windowId) {
this.targetWindowId = null;
this.targetWindowPid = null;
this.updateGeometry(null);
return;
}
this.targetWindowId = windowId;
const targetPid = this.targetWindowPid ?? (await this.getWindowPid(windowId));
this.targetWindowPid = targetPid;
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
const geometry = parseX11WindowGeometry(winInfo);
@@ -143,7 +202,9 @@ export class X11WindowTracker extends BaseWindowTracker {
return;
}
this.updateGeometry(geometry);
const focused = await this.isWindowActive(windowId, targetPid);
this.updateGeometry(geometry, focused);
this.updateTargetWindowFocused(focused);
if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) {
this.currentPollIntervalMs = this.stablePollIntervalMs;
this.resetPollInterval(this.currentPollIntervalMs);
@@ -151,12 +212,20 @@ export class X11WindowTracker extends BaseWindowTracker {
}
private async findTargetWindowId(windowIds: string[]): Promise<string | null> {
this.targetWindowId = null;
this.targetWindowPid = null;
if (!this.targetMpvSocketPath) {
return windowIds[0] ?? null;
const windowId = windowIds[0] ?? null;
if (windowId) {
this.targetWindowPid = await this.getWindowPid(windowId);
}
return windowId;
}
for (const windowId of windowIds) {
if (await this.isWindowForTargetSocket(windowId)) {
const pid = await this.getTargetSocketWindowPid(windowId);
if (pid !== null) {
this.targetWindowPid = pid;
return windowId;
}
}
@@ -164,21 +233,58 @@ export class X11WindowTracker extends BaseWindowTracker {
return null;
}
private async isWindowForTargetSocket(windowId: string): Promise<boolean> {
private async getTargetSocketWindowPid(windowId: string): Promise<number | null> {
const pid = await this.getWindowPid(windowId);
if (pid === null) {
return false;
return null;
}
const commandLine = await this.getWindowCommandLine(pid);
if (!commandLine) {
return false;
return null;
}
return (
const matchesTargetSocket =
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`);
return matchesTargetSocket ? pid : null;
}
private async isWindowActive(windowId: string, targetPid: number | null): Promise<boolean> {
const activeWindowId = await this.getX11ActiveWindowId();
if (!activeWindowId) {
return true;
}
const normalizedTarget = normalizeX11WindowId(windowId);
const normalizedActive = normalizeX11WindowId(activeWindowId);
if (!normalizedTarget || !normalizedActive) {
return true;
}
if (targetPid !== null) {
const activePid = await this.getWindowPid(normalizedActive);
if (activePid !== null) {
return activePid === targetPid;
}
}
return normalizedTarget === normalizedActive;
}
private async getX11ActiveWindowId(): Promise<string | null> {
try {
const rootActiveWindow = parseX11RootActiveWindowId(
await this.runCommand('xprop', ['-root', '_NET_ACTIVE_WINDOW']),
);
if (rootActiveWindow) {
return rootActiveWindow;
}
} catch {
// Fall back below. Some minimal WMs do not expose _NET_ACTIVE_WINDOW.
}
try {
return normalizeX11WindowId(await this.runCommand('xdotool', ['getactivewindow']));
} catch {
return null;
}
}
private async getWindowPid(windowId: string): Promise<number | null> {