mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 15:13:32 -07:00
fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user