Fix Windows YouTube playback flow and overlay pointer tracking

This commit is contained in:
2026-03-25 15:25:17 -07:00
committed by sudacode
parent 5ee4617607
commit c95518e94a
26 changed files with 1044 additions and 36 deletions

View File

@@ -759,6 +759,76 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
}
handlers.restorePointerInteractionState();
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [
{ ignore: false, forward: undefined },
{ ignore: false, forward: undefined },
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => ctx.dom.subtitleContainer,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
@@ -768,6 +838,82 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
}
});
test('pointer tracking restores click-through after the cursor leaves subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let hoveredElement: unknown = ctx.dom.subtitleContainer;
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => hoveredElement,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
hoveredElement = null;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 640, clientY: 360 });
}
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [
{ ignore: false, forward: undefined },
{ ignore: true, forward: true },
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;

View File

@@ -20,6 +20,12 @@ export function createMouseHandlers(
sendMpvCommand: (command: (string | number)[]) => void;
},
) {
type HoverPointState = {
overPrimarySubtitle: boolean;
overSecondarySubtitle: boolean;
isOverSubtitle: boolean;
};
let yomitanPopupVisible = false;
let hoverPauseRequestId = 0;
let popupPauseRequestId = 0;
@@ -45,7 +51,7 @@ export function createMouseHandlers(
};
}
function syncHoverStateFromPoint(clientX: number, clientY: number): boolean {
function getHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
const hoveredElement =
typeof document.elementFromPoint === 'function'
? document.elementFromPoint(clientX, clientY)
@@ -56,13 +62,52 @@ export function createMouseHandlers(
ctx.dom.secondarySubContainer,
);
ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle;
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',
overSecondarySubtitle,
hoverState.overSecondarySubtitle,
);
return ctx.state.isOverSubtitle;
return hoverState;
}
function syncHoverStateFromTrackedPointer(event: MouseEvent | PointerEvent): void {
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isDragging) {
return;
}
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);
return;
}
if (wasOverSubtitle && !hoverState.isOverSubtitle) {
void handleMouseLeave(undefined, wasOverSecondarySubtitle);
return;
}
if (
hoverState.isOverSubtitle &&
hoverState.overSecondarySubtitle !== wasOverSecondarySubtitle
) {
syncOverlayMouseIgnoreState(ctx);
}
}
function restorePointerInteractionState(): void {
@@ -293,10 +338,12 @@ export function createMouseHandlers(
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);
});
}