Files
SubMiner/src/main/runtime/linux-overlay-pointer-interaction.ts
T
sudacode 9d77907877 feat(overlay): add loading OSD spinner and queue notifications until ren
- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting
- Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable
- Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods
- Defer background warmups until after overlay runtime init so queued notifications can deliver promptly
- Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
2026-06-08 02:22:54 -07:00

371 lines
13 KiB
TypeScript

/*
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 hasMeasuredInputRects(measurement: OverlayContentMeasurementLike): boolean {
return measuredRectsForInput(measurement).some((rect) => rect.width > 0 && rect.height > 0);
}
export function shouldPrimeLinuxOverlayInteractionFromMeasurement(deps: {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionWindow | null;
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
shouldSuspend: () => boolean;
shouldSuppressInteraction?: () => boolean;
}): boolean {
if (!deps.getVisibleOverlayVisible()) return false;
if (deps.shouldSuspend()) return false;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return false;
}
if (deps.shouldSuppressInteraction?.()) return false;
return hasMeasuredInputRects(deps.getSubtitleMeasurement());
}
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;
}