fix(renderer): restore subtitle sidebar mpv passthrough

This commit is contained in:
2026-03-29 15:22:03 -07:00
parent a55819e90d
commit 6648ed1332
3 changed files with 318 additions and 17 deletions

View File

@@ -1241,6 +1241,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1317,6 +1318,11 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -1333,7 +1339,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
await modal.openSubtitleSidebarModal();
await modal.refreshSubtitleSidebarSnapshot();
mpvCommands.length = 0;
await modalListeners.get('mouseenter')?.[0]?.();
await contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'yes']);
@@ -1353,6 +1359,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalListeners = new Map<string, Array<() => Promise<void> | void>>();
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1431,6 +1438,11 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -1446,7 +1458,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
await modal.openSubtitleSidebarModal();
await assert.doesNotReject(async () => {
await modalListeners.get('mouseenter')?.[0]?.();
await contentListeners.get('mouseenter')?.[0]?.();
});
assert.equal(state.subtitleSidebarPausedByHover, false);
@@ -1744,6 +1756,7 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
const mpvCommands: Array<Array<string | number>> = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1823,6 +1836,11 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -1842,15 +1860,15 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
modalListeners.get('mouseenter')?.[0]?.();
contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
modalListeners.get('mouseleave')?.[0]?.();
contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
state.isOverSubtitle = true;
modalListeners.get('mouseenter')?.[0]?.();
modalListeners.get('mouseleave')?.[0]?.();
contentListeners.get('mouseenter')?.[0]?.();
contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
void mpvCommands;
@@ -1860,6 +1878,251 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
}
});
test('subtitle sidebar overlay layout restores macOS and Windows passthrough outside sidebar hover', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 360,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreMouseCalls.push([ignore, options]);
},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: (type: string, listener: () => void) => {
const bucket = modalListeners.get(type) ?? [];
bucket.push(listener);
modalListeners.set(type, bucket);
},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
platform: {
shouldToggleMouseIgnore: true,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
assert.equal(modalListeners.get('mouseenter')?.length ?? 0, 0);
assert.equal(modalListeners.get('mouseleave')?.length ?? 0, 0);
assert.equal(contentListeners.get('mouseenter')?.length ?? 0, 1);
assert.equal(contentListeners.get('mouseleave')?.length ?? 0, 1);
await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
void mpvCommands;
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar overlay layout only stays interactive while focus remains inside the sidebar panel', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 360,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreMouseCalls.push([ignore, options]);
},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const sidebarContent = {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: (event?: FocusEvent) => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
contains: () => false,
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: sidebarContent,
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
platform: {
shouldToggleMouseIgnore: true,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
contentListeners.get('focusin')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
contentListeners.get('focusout')?.[0]?.({ relatedTarget: null } as FocusEvent);
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('closing embedded subtitle sidebar recomputes passthrough from remaining subtitle hover state', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;

View File

@@ -143,11 +143,23 @@ export function createSubtitleSidebarModal(
let lastAppliedVideoMarginRatio: number | null = null;
let subtitleSidebarHoverRequestId = 0;
let disposeDomEvents: (() => void) | null = null;
let subtitleSidebarHovered = false;
let subtitleSidebarFocusedWithin = false;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
}
function syncSidebarInteractionState(): void {
ctx.state.isOverSubtitleSidebar = subtitleSidebarHovered || subtitleSidebarFocusedWithin;
}
function clearSidebarInteractionState(): void {
subtitleSidebarHovered = false;
subtitleSidebarFocusedWithin = false;
syncSidebarInteractionState();
}
function setStatus(message: string): void {
ctx.dom.subtitleSidebarStatus.textContent = message;
}
@@ -379,6 +391,7 @@ export function createSubtitleSidebarModal(
applyConfig(snapshot);
if (!snapshot.config.enabled) {
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.subtitleSidebarCues = [];
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
@@ -450,7 +463,7 @@ export function createSubtitleSidebarModal(
}
ctx.state.subtitleSidebarModalOpen = true;
ctx.state.isOverSubtitleSidebar = false;
clearSidebarInteractionState();
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
renderCueList();
@@ -478,7 +491,7 @@ export function createSubtitleSidebarModal(
return;
}
resumeSubtitleSidebarHoverPause();
ctx.state.isOverSubtitleSidebar = false;
clearSidebarInteractionState();
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
@@ -536,8 +549,9 @@ export function createSubtitleSidebarModal(
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
ctx.state.isOverSubtitleSidebar = true;
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
subtitleSidebarHovered = true;
syncSidebarInteractionState();
restoreEmbeddedSidebarPassthrough();
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
return;
@@ -557,8 +571,36 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarPausedByHover = true;
}
});
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
ctx.state.isOverSubtitleSidebar = false;
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
subtitleSidebarHovered = false;
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
return;
}
resumeSubtitleSidebarHoverPause();
});
ctx.dom.subtitleSidebarContent.addEventListener('focusin', () => {
subtitleSidebarFocusedWithin = true;
syncSidebarInteractionState();
restoreEmbeddedSidebarPassthrough();
});
ctx.dom.subtitleSidebarContent.addEventListener('focusout', (event: FocusEvent) => {
const relatedTarget = event.relatedTarget;
if (
typeof Node !== 'undefined' &&
relatedTarget instanceof Node &&
ctx.dom.subtitleSidebarContent.contains(relatedTarget)
) {
return;
}
subtitleSidebarFocusedWithin = false;
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
return;
}
resumeSubtitleSidebarHoverPause();
});
const resizeHandler = () => {

View File

@@ -2,9 +2,6 @@ import type { RendererContext } from './context';
import type { RendererState } from './state';
function isBlockingOverlayModalOpen(state: RendererState): boolean {
const embeddedSidebarOpen =
state.subtitleSidebarModalOpen && state.subtitleSidebarConfig?.layout === 'embedded';
return Boolean(
state.controllerSelectModalOpen ||
state.controllerDebugModalOpen ||
@@ -13,8 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subsyncModalOpen ||
state.sessionHelpModalOpen ||
(state.subtitleSidebarModalOpen && !embeddedSidebarOpen),
state.sessionHelpModalOpen,
);
}