mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 16:19:27 -07:00
Use native synchronous z-order binding (koffi) instead of async PowerShell for overlay positioning, eliminating the 200-500ms delay that left the overlay behind mpv after restore. Hide the overlay immediately when mpv is minimized so the full show/reveal/z-order flow triggers cleanly on restore. Also adds hover suppression after visibility recovery and window resize to prevent spurious auto-pause, Windows secondary subtitle titlebar fix, and z-order sync burst retries on geometry changes.
522 lines
16 KiB
TypeScript
522 lines
16 KiB
TypeScript
import type { ModalStateReader, RendererContext } from '../context';
|
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
|
import {
|
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
|
YOMITAN_POPUP_SHOWN_EVENT,
|
|
isYomitanPopupVisible,
|
|
isYomitanPopupIframe,
|
|
} from '../yomitan-popup.js';
|
|
|
|
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
|
|
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
|
|
|
|
export function createMouseHandlers(
|
|
ctx: RendererContext,
|
|
options: {
|
|
modalStateReader: ModalStateReader;
|
|
applyYPercent: (yPercent: number) => void;
|
|
getCurrentYPercent: () => number;
|
|
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
|
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
|
getYomitanPopupAutoPauseEnabled: () => boolean;
|
|
getPlaybackPaused: () => Promise<boolean | null>;
|
|
sendMpvCommand: (command: (string | number)[]) => void;
|
|
},
|
|
) {
|
|
type HoverPointState = {
|
|
overPrimarySubtitle: boolean;
|
|
overSecondarySubtitle: boolean;
|
|
isOverSubtitle: boolean;
|
|
};
|
|
|
|
let yomitanPopupVisible = false;
|
|
let hoverPauseRequestId = 0;
|
|
let popupPauseRequestId = 0;
|
|
let pausedBySubtitleHover = false;
|
|
let pausedByYomitanPopup = false;
|
|
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
|
let pendingPointerResync = false;
|
|
let suppressDirectHoverEnterSource: string | null = null;
|
|
|
|
function getPopupVisibilityFromDom(): boolean {
|
|
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
|
}
|
|
|
|
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
|
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
|
yomitanPopupVisible = popupVisible;
|
|
ctx.state.yomitanPopupVisible = popupVisible;
|
|
return popupVisible;
|
|
}
|
|
|
|
function reclaimOverlayWindowFocusForPopup(): void {
|
|
if (!ctx.platform.shouldToggleMouseIgnore) {
|
|
return;
|
|
}
|
|
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
|
return;
|
|
}
|
|
|
|
if (typeof window.electronAPI.focusMainWindow === 'function') {
|
|
void window.electronAPI.focusMainWindow();
|
|
}
|
|
window.focus();
|
|
if (typeof ctx.dom.overlay.focus === 'function') {
|
|
ctx.dom.overlay.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
function sustainPopupInteraction(): void {
|
|
syncPopupVisibilityState(true);
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
}
|
|
|
|
function reconcilePopupInteraction(args: {
|
|
assumeVisible?: boolean;
|
|
reclaimFocus?: boolean;
|
|
allowPause?: boolean;
|
|
} = {}): boolean {
|
|
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
|
|
if (!popupVisible) {
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
return false;
|
|
}
|
|
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
if (args.reclaimFocus === true) {
|
|
reclaimOverlayWindowFocusForPopup();
|
|
}
|
|
if (args.allowPause === true) {
|
|
void maybePauseForYomitanPopup();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
|
if (!element) {
|
|
return false;
|
|
}
|
|
if (element === container) {
|
|
return true;
|
|
}
|
|
return typeof container.contains === 'function' ? container.contains(element) : false;
|
|
}
|
|
|
|
function updatePointerPosition(event: MouseEvent | PointerEvent): void {
|
|
lastPointerPosition = {
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
};
|
|
}
|
|
|
|
function getHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
|
|
const hoveredElement =
|
|
typeof document.elementFromPoint === 'function'
|
|
? document.elementFromPoint(clientX, clientY)
|
|
: null;
|
|
const overPrimarySubtitle = isElementWithinContainer(hoveredElement, ctx.dom.subtitleContainer);
|
|
const overSecondarySubtitle = isElementWithinContainer(
|
|
hoveredElement,
|
|
ctx.dom.secondarySubContainer,
|
|
);
|
|
|
|
return {
|
|
overPrimarySubtitle,
|
|
overSecondarySubtitle,
|
|
isOverSubtitle: overPrimarySubtitle || overSecondarySubtitle,
|
|
};
|
|
}
|
|
|
|
function syncHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
|
|
const hoverState = getHoverStateFromPoint(clientX, clientY);
|
|
|
|
ctx.state.isOverSubtitle = hoverState.isOverSubtitle;
|
|
ctx.dom.secondarySubContainer.classList.toggle(
|
|
'secondary-sub-hover-active',
|
|
hoverState.overSecondarySubtitle,
|
|
);
|
|
|
|
return hoverState;
|
|
}
|
|
|
|
function syncHoverStateFromTrackedPointer(event: MouseEvent | PointerEvent): void {
|
|
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isDragging) {
|
|
return;
|
|
}
|
|
|
|
suppressDirectHoverEnterSource = null;
|
|
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
|
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
|
'secondary-sub-hover-active',
|
|
);
|
|
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
|
|
|
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
|
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
|
|
return;
|
|
}
|
|
|
|
if (wasOverSubtitle && !hoverState.isOverSubtitle) {
|
|
void handleMouseLeave(undefined, wasOverSecondarySubtitle);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
hoverState.isOverSubtitle &&
|
|
hoverState.overSecondarySubtitle !== wasOverSecondarySubtitle
|
|
) {
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
}
|
|
}
|
|
|
|
function resyncPointerInteractionState(options: {
|
|
allowInteractiveFallback: boolean;
|
|
suppressDirectHoverEnterSource?: string | null;
|
|
}): void {
|
|
const pointerPosition = lastPointerPosition;
|
|
pendingPointerResync = false;
|
|
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
|
|
if (pointerPosition) {
|
|
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
|
|
} else {
|
|
ctx.state.isOverSubtitle = false;
|
|
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
|
}
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
|
|
if (
|
|
!options.allowInteractiveFallback ||
|
|
!ctx.platform.shouldToggleMouseIgnore ||
|
|
ctx.state.isOverSubtitle
|
|
) {
|
|
return;
|
|
}
|
|
|
|
pendingPointerResync = true;
|
|
ctx.dom.overlay.classList.add('interactive');
|
|
window.electronAPI.setIgnoreMouseEvents(false);
|
|
}
|
|
|
|
function restorePointerInteractionState(): void {
|
|
resyncPointerInteractionState({ allowInteractiveFallback: true });
|
|
}
|
|
|
|
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
|
|
if (!pendingPointerResync) {
|
|
return;
|
|
}
|
|
pendingPointerResync = false;
|
|
syncHoverStateFromPoint(event.clientX, event.clientY);
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
}
|
|
|
|
function isWithinOtherSubtitleContainer(
|
|
relatedTarget: EventTarget | null,
|
|
otherContainer: HTMLElement,
|
|
): boolean {
|
|
if (relatedTarget === otherContainer) {
|
|
return true;
|
|
}
|
|
if (typeof Node !== 'undefined' && relatedTarget instanceof Node) {
|
|
return otherContainer.contains(relatedTarget);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function maybeResumeHoverPause(): void {
|
|
if (!pausedBySubtitleHover) return;
|
|
if (pausedByYomitanPopup) return;
|
|
if (ctx.state.isOverSubtitle) return;
|
|
pausedBySubtitleHover = false;
|
|
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
|
}
|
|
|
|
function maybeResumeYomitanPopupPause(): void {
|
|
if (!pausedByYomitanPopup) return;
|
|
pausedByYomitanPopup = false;
|
|
if (ctx.state.isOverSubtitle && options.getSubtitleHoverAutoPauseEnabled()) {
|
|
pausedBySubtitleHover = true;
|
|
return;
|
|
}
|
|
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
|
}
|
|
|
|
async function maybePauseForYomitanPopup(): Promise<void> {
|
|
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
|
|
return;
|
|
}
|
|
|
|
const requestId = ++popupPauseRequestId;
|
|
if (pausedByYomitanPopup) return;
|
|
|
|
if (pausedBySubtitleHover) {
|
|
pausedBySubtitleHover = false;
|
|
pausedByYomitanPopup = true;
|
|
return;
|
|
}
|
|
|
|
let paused: boolean | null = null;
|
|
try {
|
|
paused = await options.getPlaybackPaused();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
requestId !== popupPauseRequestId ||
|
|
!yomitanPopupVisible ||
|
|
!options.getYomitanPopupAutoPauseEnabled()
|
|
) {
|
|
return;
|
|
}
|
|
if (paused !== false) return;
|
|
|
|
options.sendMpvCommand(['set_property', 'pause', 'yes']);
|
|
pausedByYomitanPopup = true;
|
|
}
|
|
|
|
function enablePopupInteraction(): void {
|
|
sustainPopupInteraction();
|
|
if (ctx.platform.isMacOSPlatform) {
|
|
window.focus();
|
|
}
|
|
}
|
|
|
|
function disablePopupInteractionIfIdle(): void {
|
|
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
|
return;
|
|
}
|
|
|
|
yomitanPopupVisible = false;
|
|
ctx.state.yomitanPopupVisible = false;
|
|
popupPauseRequestId += 1;
|
|
maybeResumeYomitanPopupPause();
|
|
maybeResumeHoverPause();
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
}
|
|
|
|
async function handleMouseEnter(
|
|
_event?: MouseEvent,
|
|
showSecondaryHover = false,
|
|
source: 'direct' | 'tracked-pointer' = 'direct',
|
|
): Promise<void> {
|
|
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
|
|
return;
|
|
}
|
|
|
|
ctx.state.isOverSubtitle = true;
|
|
if (showSecondaryHover) {
|
|
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
|
}
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
|
|
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (!options.getSubtitleHoverAutoPauseEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (pausedBySubtitleHover) {
|
|
return;
|
|
}
|
|
|
|
const requestId = ++hoverPauseRequestId;
|
|
let paused: boolean | null = null;
|
|
try {
|
|
paused = await options.getPlaybackPaused();
|
|
} catch {
|
|
return;
|
|
}
|
|
if (requestId !== hoverPauseRequestId || !ctx.state.isOverSubtitle) {
|
|
return;
|
|
}
|
|
if (paused !== false) {
|
|
return;
|
|
}
|
|
options.sendMpvCommand(['set_property', 'pause', 'yes']);
|
|
pausedBySubtitleHover = true;
|
|
}
|
|
|
|
async function handleMouseLeave(_event?: MouseEvent, hideSecondaryHover = false): Promise<void> {
|
|
const relatedTarget = _event?.relatedTarget ?? null;
|
|
const otherContainer = hideSecondaryHover
|
|
? ctx.dom.subtitleContainer
|
|
: ctx.dom.secondarySubContainer;
|
|
if (relatedTarget && isWithinOtherSubtitleContainer(relatedTarget, otherContainer)) {
|
|
ctx.state.isOverSubtitle = false;
|
|
if (hideSecondaryHover) {
|
|
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
|
}
|
|
return;
|
|
}
|
|
|
|
ctx.state.isOverSubtitle = false;
|
|
if (hideSecondaryHover) {
|
|
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
|
}
|
|
hoverPauseRequestId += 1;
|
|
maybeResumeHoverPause();
|
|
if (yomitanPopupVisible) return;
|
|
disablePopupInteractionIfIdle();
|
|
}
|
|
|
|
function setupDragging(): void {
|
|
ctx.dom.subtitleContainer.addEventListener('mousedown', (e: MouseEvent) => {
|
|
if (e.button === 2) {
|
|
e.preventDefault();
|
|
ctx.state.isDragging = true;
|
|
ctx.state.dragStartY = e.clientY;
|
|
ctx.state.startYPercent = options.getCurrentYPercent();
|
|
ctx.dom.subtitleContainer.style.cursor = 'grabbing';
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e: MouseEvent) => {
|
|
if (!ctx.state.isDragging) return;
|
|
|
|
const deltaY = ctx.state.dragStartY - e.clientY;
|
|
const deltaPercent = (deltaY / window.innerHeight) * 100;
|
|
const newYPercent = ctx.state.startYPercent + deltaPercent;
|
|
|
|
options.applyYPercent(newYPercent);
|
|
});
|
|
|
|
document.addEventListener('mouseup', (e: MouseEvent) => {
|
|
if (ctx.state.isDragging && e.button === 2) {
|
|
ctx.state.isDragging = false;
|
|
ctx.dom.subtitleContainer.style.cursor = '';
|
|
|
|
const yPercent = options.getCurrentYPercent();
|
|
options.persistSubtitlePositionPatch({ yPercent });
|
|
}
|
|
});
|
|
|
|
ctx.dom.subtitleContainer.addEventListener('contextmenu', (e: Event) => {
|
|
e.preventDefault();
|
|
});
|
|
}
|
|
|
|
function setupResizeHandler(): void {
|
|
window.addEventListener('resize', () => {
|
|
options.applyYPercent(options.getCurrentYPercent());
|
|
resyncPointerInteractionState({
|
|
allowInteractiveFallback: false,
|
|
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupPointerTracking(): void {
|
|
document.addEventListener('mousemove', (event: MouseEvent) => {
|
|
updatePointerPosition(event);
|
|
syncHoverStateFromTrackedPointer(event);
|
|
maybeResyncPointerHoverState(event);
|
|
});
|
|
document.addEventListener('pointermove', (event: PointerEvent) => {
|
|
updatePointerPosition(event);
|
|
syncHoverStateFromTrackedPointer(event);
|
|
maybeResyncPointerHoverState(event);
|
|
});
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
resyncPointerInteractionState({
|
|
allowInteractiveFallback: false,
|
|
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupSelectionObserver(): void {
|
|
document.addEventListener('selectionchange', () => {
|
|
const selection = window.getSelection();
|
|
const hasSelection = selection && selection.rangeCount > 0 && !selection.isCollapsed;
|
|
|
|
if (hasSelection) {
|
|
ctx.dom.subtitleRoot.classList.add('has-selection');
|
|
} else {
|
|
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupYomitanObserver(): void {
|
|
reconcilePopupInteraction({ allowPause: true });
|
|
|
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
|
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
|
});
|
|
|
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
|
disablePopupInteractionIfIdle();
|
|
});
|
|
|
|
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
|
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
|
});
|
|
|
|
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
|
reconcilePopupInteraction({ assumeVisible: true });
|
|
});
|
|
|
|
window.addEventListener('focus', () => {
|
|
reconcilePopupInteraction();
|
|
});
|
|
|
|
window.addEventListener('blur', () => {
|
|
queueMicrotask(() => {
|
|
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
reconcilePopupInteraction({ reclaimFocus: true });
|
|
});
|
|
});
|
|
|
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
|
for (const mutation of mutations) {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
const element = node as Element;
|
|
if (isYomitanPopupIframe(element)) {
|
|
enablePopupInteraction();
|
|
void maybePauseForYomitanPopup();
|
|
}
|
|
});
|
|
|
|
mutation.removedNodes.forEach((node) => {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
const element = node as Element;
|
|
if (isYomitanPopupIframe(element)) {
|
|
disablePopupInteractionIfIdle();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
|
|
return {
|
|
handlePrimaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, false),
|
|
handlePrimaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, false),
|
|
handleSecondaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, true),
|
|
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
|
|
handleMouseEnter,
|
|
handleMouseLeave,
|
|
restorePointerInteractionState,
|
|
setupDragging,
|
|
setupPointerTracking,
|
|
setupResizeHandler,
|
|
setupSelectionObserver,
|
|
setupYomitanObserver,
|
|
};
|
|
}
|