mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
Fix Windows Anki startup and overlay regressions (#128)
This commit is contained in:
@@ -2386,6 +2386,36 @@ test('buildMergedDictionary rebuilds snapshots written with an older format vers
|
||||
}
|
||||
});
|
||||
|
||||
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const mpvPath =
|
||||
'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv';
|
||||
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => null,
|
||||
getCurrentVideoPath: () => mpvPath,
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
|
||||
calls.push({ mediaPath, mediaTitle });
|
||||
return {
|
||||
title: 'KonoSuba - God’s blessing on this wonderful world!!',
|
||||
season: 2,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
};
|
||||
},
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||
|
||||
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
|
||||
assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!');
|
||||
assert.equal(snapshot.candidates.length, 0);
|
||||
});
|
||||
|
||||
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
function trimToNull(input: string | null | undefined): string | null {
|
||||
const trimmed = typeof input === 'string' ? input.trim() : '';
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function isVideoFile(filePath: string): boolean {
|
||||
return hasVideoExtension(path.extname(filePath));
|
||||
}
|
||||
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
return dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
: {
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
mediaPath:
|
||||
trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
|
||||
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
|
||||
export interface CharacterDictionaryRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentVideoPath?: () => string | null | undefined;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
guessAnilistMediaInfo: (
|
||||
|
||||
@@ -308,7 +308,7 @@ test('visible overlay content-ready does not tokenize before first measurement',
|
||||
);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
||||
test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
@@ -317,6 +317,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
@@ -324,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
||||
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
|
||||
|
||||
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||
overlayManager: {
|
||||
@@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let windowsOverlayPointerInteractionActive = false;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||
@@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
|
||||
function resetVisibleOverlayInputState(): void {
|
||||
visibleOverlayInteractionActive = false;
|
||||
windowsOverlayPointerInteractionActive = false;
|
||||
linuxOverlayInputShapeActive = false;
|
||||
linuxOverlayPointerInteractionStateApplied = false;
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
@@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
|
||||
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||
tickWindowsOverlayPointerInteractionNow();
|
||||
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -571,6 +575,56 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuspendWindowsOverlayPointerInteraction(): boolean {
|
||||
return (
|
||||
deps.getModalInputExclusive() ||
|
||||
deps.getStatsOverlayVisible() ||
|
||||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows())
|
||||
);
|
||||
}
|
||||
|
||||
function updateWindowsOverlayPointerInteractionActive(active: boolean): void {
|
||||
windowsOverlayPointerInteractionActive = active;
|
||||
visibleOverlayInteractionActive = active;
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
!mainWindow ||
|
||||
mainWindow.isDestroyed() ||
|
||||
!mainWindow.isVisible()
|
||||
) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
|
||||
const windowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
|
||||
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
|
||||
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
|
||||
getInteractionActive: () => windowsOverlayPointerInteractionActive,
|
||||
setInteractionActive: updateWindowsOverlayPointerInteractionActive,
|
||||
};
|
||||
|
||||
function tickWindowsOverlayPointerInteractionNow(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
if (!windowsOverlayPointerInteractionActive && visibleOverlayInteractionActive) {
|
||||
return;
|
||||
}
|
||||
tickWindowsOverlayPointerInteraction(windowsOverlayPointerInteractionDeps);
|
||||
}
|
||||
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||
|
||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||
@@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
updateLinuxOverlayPointerInteractionActive,
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||
requestLinuxOverlayZOrderFollow,
|
||||
tickWindowsOverlayPointerInteractionNow,
|
||||
tickLinuxOverlayPointerInteractionNow,
|
||||
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||
visibleOverlayInteractionActive = active;
|
||||
windowsOverlayPointerInteractionActive = false;
|
||||
},
|
||||
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
isCursorOverWindowsOverlayInteractiveRect,
|
||||
resolveDesiredWindowsOverlayInteractive,
|
||||
tickWindowsOverlayPointerInteraction,
|
||||
type WindowsOverlayPointerInteractionDeps,
|
||||
} from './windows-overlay-pointer-interaction';
|
||||
import type { OverlayContentMeasurement } from '../../types';
|
||||
|
||||
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
|
||||
const MEASUREMENT: OverlayContentMeasurement = {
|
||||
layer: 'visible',
|
||||
measuredAtMs: 1,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 800, y: 900, width: 320, height: 80 },
|
||||
};
|
||||
|
||||
function makeDeps(overrides: Partial<WindowsOverlayPointerInteractionDeps>): {
|
||||
deps: WindowsOverlayPointerInteractionDeps;
|
||||
state: { active: boolean };
|
||||
} {
|
||||
const state = { active: false };
|
||||
const deps: WindowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
shouldSuspend: () => false,
|
||||
getInteractionActive: () => state.active,
|
||||
setInteractionActive: (active) => {
|
||||
state.active = active;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { deps, state };
|
||||
}
|
||||
|
||||
test('isCursorOverWindowsOverlayInteractiveRect hit-tests measured overlay rects', () => {
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isCursorOverWindowsOverlayInteractiveRect scales viewport px to window px', () => {
|
||||
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 1700, y: 1900 }, scaled, MEASUREMENT),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isCursorOverWindowsOverlayInteractiveRect uses separate interactive rects', () => {
|
||||
const measurement: OverlayContentMeasurement = {
|
||||
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.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 300 }, BOUNDS, measurement),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 180 }, BOUNDS, measurement),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 1060 }, BOUNDS, measurement),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredWindowsOverlayInteractive: interactive over subtitle, passthrough off it', () => {
|
||||
assert.equal(resolveDesiredWindowsOverlayInteractive(makeDeps({}).deps), true);
|
||||
assert.equal(
|
||||
resolveDesiredWindowsOverlayInteractive(
|
||||
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredWindowsOverlayInteractive returns null while another surface owns input', () => {
|
||||
assert.equal(
|
||||
resolveDesiredWindowsOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredWindowsOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('tickWindowsOverlayPointerInteraction toggles only the fallback-owned state', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps, state } = makeDeps({
|
||||
setInteractionActive: (active) => {
|
||||
calls.push(active);
|
||||
state.active = active;
|
||||
},
|
||||
});
|
||||
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, [true]);
|
||||
|
||||
deps.getCursorScreenPoint = () => ({ x: 200, y: 200 });
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, [true, false]);
|
||||
});
|
||||
|
||||
test('tickWindowsOverlayPointerInteraction leaves renderer-owned state alone while suspended', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getInteractionActive: () => true,
|
||||
shouldSuspend: () => true,
|
||||
setInteractionActive: (active) => calls.push(active),
|
||||
});
|
||||
|
||||
tickWindowsOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
|
||||
|
||||
type PointerPoint = { x: number; y: number };
|
||||
type PointerRect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
type PointerInteractionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
getBounds: () => PointerRect;
|
||||
};
|
||||
|
||||
export type WindowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionWindow | null;
|
||||
getCursorScreenPoint: () => PointerPoint;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurement | null;
|
||||
getRendererInteractiveHint?: () => boolean;
|
||||
/** True when a modal/stats/separate window owns input. */
|
||||
shouldSuspend: () => boolean;
|
||||
getInteractionActive: () => boolean;
|
||||
setInteractionActive: (active: boolean) => void;
|
||||
};
|
||||
|
||||
// Match Linux fallback padding so hover survives tiny measurement/cursor gaps.
|
||||
const SUBTITLE_HIT_PADDING_PX = 6;
|
||||
|
||||
function measuredRectsForInput(
|
||||
measurement: OverlayContentMeasurement | null,
|
||||
): OverlayContentRect[] {
|
||||
if (!measurement) return [];
|
||||
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
|
||||
? measurement.interactiveRects
|
||||
: measurement.contentRect
|
||||
? [measurement.contentRect]
|
||||
: [];
|
||||
}
|
||||
|
||||
function isCursorOverRect(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
viewport: { width: number; height: number },
|
||||
rect: OverlayContentRect,
|
||||
): boolean {
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
|
||||
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
|
||||
if (!(rect.width > 0) || !(rect.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;
|
||||
}
|
||||
|
||||
export function isCursorOverWindowsOverlayInteractiveRect(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
measurement: OverlayContentMeasurement | null,
|
||||
): boolean {
|
||||
if (!measurement) return false;
|
||||
return measuredRectsForInput(measurement).some((rect) =>
|
||||
isCursorOverRect(cursor, bounds, measurement.viewport, rect),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the desired Windows overlay mouse-input state, or null when another surface
|
||||
* currently owns interaction and the fallback should not touch BrowserWindow passthrough.
|
||||
*/
|
||||
export function resolveDesiredWindowsOverlayInteractive(
|
||||
deps: WindowsOverlayPointerInteractionDeps,
|
||||
): 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.getRendererInteractiveHint?.()) return true;
|
||||
return isCursorOverWindowsOverlayInteractiveRect(
|
||||
deps.getCursorScreenPoint(),
|
||||
mainWindow.getBounds(),
|
||||
deps.getSubtitleMeasurement(),
|
||||
);
|
||||
}
|
||||
|
||||
export function tickWindowsOverlayPointerInteraction(
|
||||
deps: WindowsOverlayPointerInteractionDeps,
|
||||
): void {
|
||||
const desired = resolveDesiredWindowsOverlayInteractive(deps);
|
||||
if (desired === null) return;
|
||||
if (deps.getInteractionActive() === desired) return;
|
||||
deps.setInteractionActive(desired);
|
||||
}
|
||||
Reference in New Issue
Block a user