mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
9d77907877
- 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
371 lines
13 KiB
TypeScript
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;
|
|
}
|