From 6648ed13321e233425aa8fb16ba881f5e1bb1032 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 29 Mar 2026 15:22:03 -0700 Subject: [PATCH] fix(renderer): restore subtitle sidebar mpv passthrough --- src/renderer/modals/subtitle-sidebar.test.ts | 275 ++++++++++++++++++- src/renderer/modals/subtitle-sidebar.ts | 54 +++- src/renderer/overlay-mouse-ignore.ts | 6 +- 3 files changed, 318 insertions(+), 17 deletions(-) diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 13de81f..204ab66 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -1241,6 +1241,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => { const previousDocument = globals.document; const mpvCommands: Array> = []; const modalListeners = new Map void>>(); + const contentListeners = new Map 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> = []; const modalListeners = new Map Promise | void>>(); + const contentListeners = new Map Promise | 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) => { + 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> = []; const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; const modalListeners = new Map void>>(); + const contentListeners = new Map 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> = []; + const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; + const modalListeners = new Map void>>(); + const contentListeners = new Map 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) => { + 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 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; diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 6054e08..d381606 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -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 = () => { diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index 4f7f845..401277a 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -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, ); }