From b049cf388da54e670adadfae45500b6489ba6efe Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 21 Mar 2026 16:28:30 -0700 Subject: [PATCH] fix(subtitle-sidebar): address latest CodeRabbit review --- src/config/resolve/subtitle-domains.ts | 24 +- src/config/resolve/subtitle-sidebar.test.ts | 27 + src/renderer/handlers/mouse.test.ts | 137 +- src/renderer/modals/subtitle-sidebar.test.ts | 1227 +++++++++++++++++- src/renderer/modals/subtitle-sidebar.ts | 152 ++- src/renderer/style.css | 20 +- 6 files changed, 1550 insertions(+), 37 deletions(-) diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 25e12c6..2d88bb6 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -3,6 +3,7 @@ import { ResolveContext } from './context'; import { asBoolean, asColor, + asCssColor, asFrequencyBandedColors, asNumber, asString, @@ -439,6 +440,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ); } + const autoOpen = asBoolean((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen); + if (autoOpen !== undefined) { + resolved.subtitleSidebar.autoOpen = autoOpen; + } else if ((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen !== undefined) { + resolved.subtitleSidebar.autoOpen = fallback.autoOpen; + warn( + 'subtitleSidebar.autoOpen', + (src.subtitleSidebar as { autoOpen?: unknown }).autoOpen, + resolved.subtitleSidebar.autoOpen, + 'Expected boolean.', + ); + } + const layout = asString((src.subtitleSidebar as { layout?: unknown }).layout); if (layout === 'overlay' || layout === 'embedded') { resolved.subtitleSidebar.layout = layout; @@ -507,7 +521,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { } const opacity = asNumber((src.subtitleSidebar as { opacity?: unknown }).opacity); - if (opacity !== undefined && opacity > 0 && opacity <= 1) { + if (opacity !== undefined && opacity >= 0 && opacity <= 1) { resolved.subtitleSidebar.opacity = opacity; } else if ((src.subtitleSidebar as { opacity?: unknown }).opacity !== undefined) { resolved.subtitleSidebar.opacity = fallback.opacity; @@ -541,16 +555,16 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { 'hoverLineBackgroundColor', ] as const; for (const field of cssColorFields) { - const value = asString((src.subtitleSidebar as Record)[field]); - if (value !== undefined && value.trim().length > 0) { - resolved.subtitleSidebar[field] = value.trim(); + const value = asCssColor((src.subtitleSidebar as Record)[field]); + if (value !== undefined) { + resolved.subtitleSidebar[field] = value; } else if ((src.subtitleSidebar as Record)[field] !== undefined) { resolved.subtitleSidebar[field] = fallback[field]; warn( `subtitleSidebar.${field}`, (src.subtitleSidebar as Record)[field], resolved.subtitleSidebar[field], - 'Expected string.', + 'Expected valid CSS color.', ); } } diff --git a/src/config/resolve/subtitle-sidebar.test.ts b/src/config/resolve/subtitle-sidebar.test.ts index d8e4db6..7c23247 100644 --- a/src/config/resolve/subtitle-sidebar.test.ts +++ b/src/config/resolve/subtitle-sidebar.test.ts @@ -7,6 +7,7 @@ test('subtitleSidebar resolves valid values and preserves dedicated defaults', ( const { context } = createResolveContext({ subtitleSidebar: { enabled: true, + autoOpen: true, layout: 'embedded', toggleKey: 'KeyB', pauseVideoOnHover: true, @@ -27,6 +28,7 @@ test('subtitleSidebar resolves valid values and preserves dedicated defaults', ( applySubtitleDomainConfig(context); assert.equal(context.resolved.subtitleSidebar.enabled, true); + assert.equal(context.resolved.subtitleSidebar.autoOpen, true); assert.equal(context.resolved.subtitleSidebar.layout, 'embedded'); assert.equal(context.resolved.subtitleSidebar.toggleKey, 'KeyB'); assert.equal(context.resolved.subtitleSidebar.pauseVideoOnHover, true); @@ -37,30 +39,55 @@ test('subtitleSidebar resolves valid values and preserves dedicated defaults', ( assert.equal(context.resolved.subtitleSidebar.fontSize, 17); }); +test('subtitleSidebar accepts zero opacity', () => { + const { context, warnings } = createResolveContext({ + subtitleSidebar: { + opacity: 0, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleSidebar.opacity, 0); + assert.equal(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), false); +}); + test('subtitleSidebar falls back and warns on invalid values', () => { const { context, warnings } = createResolveContext({ subtitleSidebar: { enabled: 'yes' as never, + autoOpen: 'yes' as never, layout: 'floating' as never, maxWidth: -1, opacity: 5, fontSize: 0, textColor: 'blue', + backgroundColor: 'not-a-color', }, }); applySubtitleDomainConfig(context); assert.equal(context.resolved.subtitleSidebar.enabled, false); + assert.equal(context.resolved.subtitleSidebar.autoOpen, false); assert.equal(context.resolved.subtitleSidebar.layout, 'overlay'); assert.equal(context.resolved.subtitleSidebar.maxWidth, 420); assert.equal(context.resolved.subtitleSidebar.opacity, 0.95); assert.equal(context.resolved.subtitleSidebar.fontSize, 16); assert.equal(context.resolved.subtitleSidebar.textColor, '#cad3f5'); + assert.equal(context.resolved.subtitleSidebar.backgroundColor, 'rgba(73, 77, 100, 0.9)'); assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.enabled')); + assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.autoOpen')); assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.layout')); assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.maxWidth')); assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity')); assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.fontSize')); assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.textColor')); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleSidebar.backgroundColor' && + warning.message === 'Expected valid CSS color.', + ), + ); }); diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index fd514a1..5903a3f 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import type { SubtitleSidebarConfig } from '../../types'; import { createMouseHandlers } from './mouse.js'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; @@ -39,6 +40,7 @@ function createMouseTestContext() { const overlayClassList = createClassList(); const subtitleRootClassList = createClassList(); const subtitleContainerClassList = createClassList(); + const secondarySubContainerClassList = createClassList(); const ctx = { dom: { @@ -54,6 +56,7 @@ function createMouseTestContext() { addEventListener: () => {}, }, secondarySubContainer: { + classList: secondarySubContainerClassList, addEventListener: () => {}, }, }, @@ -63,6 +66,9 @@ function createMouseTestContext() { }, state: { isOverSubtitle: false, + isOverSubtitleSidebar: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null as SubtitleSidebarConfig | null, isDragging: false, dragStartY: 0, startYPercent: 0, @@ -72,7 +78,7 @@ function createMouseTestContext() { return ctx; } -test('auto-pause on subtitle hover pauses on enter and resumes on leave when enabled', async () => { +test('secondary hover pauses on enter, reveals secondary subtitle, and resumes on leave when enabled', async () => { const ctx = createMouseTestContext(); const mpvCommands: Array<(string | number)[]> = []; @@ -92,8 +98,10 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena }, }); - await handlers.handleMouseEnter(); - await handlers.handleMouseLeave(); + await handlers.handleSecondaryMouseEnter(); + assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true); + await handlers.handleSecondaryMouseLeave(); + assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false); assert.deepEqual(mpvCommands, [ ['set_property', 'pause', 'yes'], @@ -101,6 +109,38 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena ]); }); +test('moving between primary and secondary subtitle containers keeps the hover pause active', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + await handlers.handleSecondaryMouseEnter(); + await handlers.handleSecondaryMouseLeave({ + relatedTarget: ctx.dom.subtitleContainer, + } as unknown as MouseEvent); + await handlers.handlePrimaryMouseEnter({ + relatedTarget: ctx.dom.secondarySubContainer, + } as unknown as MouseEvent); + + assert.equal(ctx.state.isOverSubtitle, true); + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); +}); + test('auto-pause on subtitle hover skips when playback is already paused', async () => { const ctx = createMouseTestContext(); const mpvCommands: Array<(string | number)[]> = []; @@ -127,6 +167,36 @@ test('auto-pause on subtitle hover skips when playback is already paused', async assert.deepEqual(mpvCommands, []); }); +test('primary hover pauses on enter without revealing secondary subtitle', async () => { + const ctx = createMouseTestContext(); + const mpvCommands: Array<(string | number)[]> = []; + + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => true, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + await handlers.handlePrimaryMouseEnter(); + assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false); + await handlers.handlePrimaryMouseLeave(); + + assert.deepEqual(mpvCommands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); +}); + test('auto-pause on subtitle hover is skipped when disabled in config', async () => { const ctx = createMouseTestContext(); const mpvCommands: Array<(string | number)[]> = []; @@ -153,6 +223,67 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async () assert.deepEqual(mpvCommands, []); }); +test('subtitle leave restores passthrough while embedded sidebar is open but not hovered', async () => { + const ctx = createMouseTestContext(); + const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; + const previousWindow = (globalThis as { window?: unknown }).window; + + ctx.platform.shouldToggleMouseIgnore = true; + ctx.state.isOverSubtitle = true; + ctx.state.subtitleSidebarModalOpen = true; + ctx.state.subtitleSidebarConfig = { + enabled: true, + autoOpen: false, + layout: 'embedded', + 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: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreMouseCalls.push([ignore, options]); + }, + }, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => true, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + await handlers.handlePrimaryMouseLeave(); + + assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + } +}); + test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => { const ctx = createMouseTestContext(); const mpvCommands: Array<(string | number)[]> = []; diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 6add62d..9a5aceb 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -25,18 +25,29 @@ function createClassList(initialTokens: string[] = []) { } function createCueRow() { + const listeners = new Map void>>(); return { className: '', classList: createClassList(), dataset: {} as Record, textContent: '', + tabIndex: -1, offsetTop: 0, clientHeight: 40, children: [] as unknown[], appendChild(child: unknown) { this.children.push(child); }, - addEventListener: () => {}, + attributes: {} as Record, + listeners, + addEventListener(type: string, listener: (event: unknown) => void) { + const bucket = listeners.get(type) ?? []; + bucket.push(listener); + listeners.set(type, bucket); + }, + setAttribute(name: string, value: string) { + this.attributes[name] = value; + }, scrollIntoViewCalls: [] as ScrollIntoViewOptions[], scrollIntoView(options?: ScrollIntoViewOptions) { this.scrollIntoViewCalls.push(options ?? {}); @@ -91,6 +102,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback }, config: { enabled: true, + autoOpen: false, layout: 'overlay', toggleKey: 'Backslash', pauseVideoOnHover: false, @@ -169,10 +181,8 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback assert.equal(modalClassList.contains('hidden'), false); assert.equal(state.subtitleSidebarActiveCueIndex, 1); assert.equal(cueList.children.length, 2); - assert.deepEqual(cueList.scrollToCalls[0], { - top: 0, - behavior: 'auto', - }); + assert.equal(cueList.scrollTop, 0); + assert.deepEqual(cueList.scrollToCalls, []); modal.seekToCue(snapshot.cues[0]!); assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']); @@ -182,6 +192,442 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback } }); +test('subtitle sidebar rows support keyboard activation', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const mpvCommands: Array> = []; + + const snapshot: SubtitleSidebarSnapshot = { + cues: [ + { startTime: 1, endTime: 2, text: 'first' }, + { startTime: 3, endTime: 4, text: 'second' }, + ], + currentSubtitle: { + text: 'second', + startTime: 3, + endTime: 4, + }, + config: { + enabled: true, + autoOpen: false, + layout: 'overlay', + toggleKey: 'Backslash', + pauseVideoOnHover: false, + autoScroll: true, + maxWidth: 420, + 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: { + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + sendMpvCommand: (command: Array) => { + mpvCommands.push(command); + }, + } as unknown as ElectronAPI, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createCueRow(), + body: { + classList: createClassList(), + }, + documentElement: { + style: { + setProperty: () => {}, + }, + }, + }, + }); + + try { + const state = createRendererState(); + const cueList = createListStub(); + const ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: createClassList(['hidden']), + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: cueList, + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.openSubtitleSidebarModal(); + + const firstRow = cueList.children[0]!; + const keydownListeners = firstRow.listeners.get('keydown') ?? []; + assert.equal(keydownListeners.length > 0, true); + + keydownListeners[0]!({ + key: 'Enter', + preventDefault: () => {}, + }); + + assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('subtitle sidebar does not open when the feature is disabled', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const snapshot: SubtitleSidebarSnapshot = { + cues: [], + currentSubtitle: { + text: '', + startTime: null, + endTime: null, + }, + config: { + enabled: false, + autoOpen: false, + layout: 'overlay', + toggleKey: 'Backslash', + pauseVideoOnHover: false, + autoScroll: true, + maxWidth: 420, + 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: { + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + sendMpvCommand: () => {}, + } as unknown as ElectronAPI, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createCueRow(), + body: { + classList: createClassList(), + }, + documentElement: { + style: { + setProperty: () => {}, + }, + }, + }, + }); + + try { + const state = createRendererState(); + const modalClassList = createClassList(['hidden']); + const cueList = createListStub(); + const ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: modalClassList, + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: cueList, + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.openSubtitleSidebarModal(); + + assert.equal(state.subtitleSidebarModalOpen, false); + assert.equal(modalClassList.contains('hidden'), true); + assert.equal(cueList.children.length, 0); + assert.equal(ctx.dom.subtitleSidebarStatus.textContent, 'Subtitle sidebar disabled in config.'); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('subtitle sidebar auto-open on startup only opens when enabled and configured', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + let snapshot: SubtitleSidebarSnapshot = { + cues: [{ startTime: 1, endTime: 2, text: 'first' }], + currentSubtitle: { + text: 'first', + startTime: 1, + endTime: 2, + }, + config: { + enabled: true, + autoOpen: true, + layout: 'overlay', + toggleKey: 'Backslash', + pauseVideoOnHover: false, + autoScroll: true, + maxWidth: 420, + 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: { + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + sendMpvCommand: () => {}, + } 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 modalClassList = createClassList(['hidden']); + const cueList = createListStub(); + const ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: modalClassList, + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: cueList, + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.autoOpenSubtitleSidebarOnStartup(); + + assert.equal(state.subtitleSidebarModalOpen, true); + assert.equal(modalClassList.contains('hidden'), false); + assert.equal(cueList.children.length, 1); + + modal.closeSubtitleSidebarModal(); + snapshot = { + ...snapshot, + config: { + ...snapshot.config, + autoOpen: false, + }, + }; + + await modal.autoOpenSubtitleSidebarOnStartup(); + + assert.equal(state.subtitleSidebarModalOpen, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('subtitle sidebar refresh closes and clears state when config becomes disabled', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const bodyClassList = createClassList(); + let 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: 'embedded', + 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: () => {}, + } as unknown as ElectronAPI, + addEventListener: () => {}, + removeEventListener: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createCueRow(), + body: { + classList: bodyClassList, + }, + documentElement: { + style: { + setProperty: () => {}, + }, + }, + }, + }); + + try { + const state = createRendererState(); + const modalClassList = createClassList(['hidden']); + const cueList = createListStub(); + const ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: modalClassList, + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 360 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: cueList, + }, + platform: { + shouldToggleMouseIgnore: false, + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.openSubtitleSidebarModal(); + assert.equal(state.subtitleSidebarModalOpen, true); + assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), true); + + snapshot = { + ...snapshot, + cues: [], + currentSubtitle: { + text: '', + startTime: null, + endTime: null, + }, + currentTimeSec: null, + config: { + ...snapshot.config, + enabled: false, + }, + }; + + await modal.refreshSubtitleSidebarSnapshot(); + + assert.equal(state.subtitleSidebarModalOpen, false); + assert.equal(state.subtitleSidebarCues.length, 0); + assert.equal(state.subtitleSidebarActiveCueIndex, -1); + assert.equal(modalClassList.contains('hidden'), true); + assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timing', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; @@ -201,6 +647,7 @@ test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timi currentTimeSec: 10.1, config: { enabled: true, + autoOpen: false, layout: 'overlay', toggleKey: 'Backslash', pauseVideoOnHover: false, @@ -289,6 +736,15 @@ test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timi } }); +test('findActiveSubtitleCueIndex falls back to the latest matching cue when the preferred index is stale', () => { + const cues = [ + { startTime: 1, endTime: 2, text: 'same' }, + { startTime: 3, endTime: 4, text: 'same' }, + ]; + + assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: null }, null, 5), 1); +}); + test('subtitle sidebar does not regress to previous cue on text-only transition update', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; @@ -308,6 +764,7 @@ test('subtitle sidebar does not regress to previous cue on text-only transition currentTimeSec: 5.1, config: { enabled: true, + autoOpen: false, layout: 'overlay', toggleKey: 'Backslash', pauseVideoOnHover: false, @@ -396,6 +853,498 @@ test('subtitle sidebar does not regress to previous cue on text-only transition } }); +test('subtitle sidebar jumps to first resolved active cue, then resumes smooth auto-follow', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + let snapshot: SubtitleSidebarSnapshot = { + cues: Array.from({ length: 12 }, (_, index) => ({ + startTime: index * 2, + endTime: index * 2 + 1.5, + text: `line-${index}`, + })), + currentSubtitle: { + text: '', + startTime: null, + endTime: null, + }, + currentTimeSec: null, + config: { + enabled: true, + autoOpen: false, + layout: 'overlay', + toggleKey: 'Backslash', + pauseVideoOnHover: false, + autoScroll: true, + maxWidth: 420, + 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: { + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + sendMpvCommand: () => {}, + } 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 cueList = createListStub(); + const ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: createClassList(['hidden']), + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: cueList, + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.openSubtitleSidebarModal(); + assert.equal(state.subtitleSidebarActiveCueIndex, -1); + cueList.scrollToCalls.length = 0; + + snapshot = { + ...snapshot, + currentSubtitle: { + text: 'line-9', + startTime: 18, + endTime: 19.5, + }, + currentTimeSec: 18.1, + }; + + await modal.refreshSubtitleSidebarSnapshot(); + + assert.equal(state.subtitleSidebarActiveCueIndex, 9); + assert.equal(cueList.scrollTop, 260); + assert.deepEqual(cueList.scrollToCalls, []); + + cueList.scrollToCalls.length = 0; + snapshot = { + ...snapshot, + currentSubtitle: { + text: 'line-10', + startTime: 20, + endTime: 21.5, + }, + currentTimeSec: 20.1, + }; + + await modal.refreshSubtitleSidebarSnapshot(); + + assert.equal(state.subtitleSidebarActiveCueIndex, 10); + assert.deepEqual(cueList.scrollToCalls.at(-1), { + top: 300, + behavior: 'smooth', + }); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('subtitle sidebar polling schedules serialized timeouts instead of intervals', async () => { + const globals = globalThis as typeof globalThis & { + window?: unknown; + document?: unknown; + setTimeout?: typeof globalThis.setTimeout; + clearTimeout?: typeof globalThis.clearTimeout; + setInterval?: typeof globalThis.setInterval; + clearInterval?: typeof globalThis.clearInterval; + }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const previousSetTimeout = globals.setTimeout; + const previousClearTimeout = globals.clearTimeout; + const previousSetInterval = globals.setInterval; + const previousClearInterval = globals.clearInterval; + let timeoutCount = 0; + let intervalCount = 0; + + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: (callback: (...args: never[]) => void) => { + timeoutCount += 1; + return timeoutCount as unknown as ReturnType; + }, + }); + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: true, + value: () => {}, + }); + Object.defineProperty(globalThis, 'setInterval', { + configurable: true, + value: () => { + intervalCount += 1; + return intervalCount as unknown as ReturnType; + }, + }); + Object.defineProperty(globalThis, 'clearInterval', { + configurable: true, + value: () => {}, + }); + + 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: 420, + 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: { + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + sendMpvCommand: () => {}, + } as unknown as ElectronAPI, + }, + }); + 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: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.openSubtitleSidebarModal(); + + assert.equal(timeoutCount > 0, true); + assert.equal(intervalCount, 0); + } finally { + Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: previousSetTimeout }); + Object.defineProperty(globalThis, 'clearTimeout', { configurable: true, value: previousClearTimeout }); + Object.defineProperty(globalThis, 'setInterval', { configurable: true, value: previousSetInterval }); + Object.defineProperty(globalThis, 'clearInterval', { configurable: true, value: previousClearInterval }); + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('subtitle sidebar closes and resumes a hover pause', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const mpvCommands: Array> = []; + const modalListeners = 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: true, + autoScroll: true, + maxWidth: 420, + 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: { + addEventListener: () => {}, + removeEventListener: () => {}, + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + getPlaybackPaused: async () => false, + sendMpvCommand: (command: Array) => { + mpvCommands.push(command); + }, + } as unknown as ElectronAPI, + }, + }); + 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: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + modal.wireDomEvents(); + + await modal.openSubtitleSidebarModal(); + await modal.refreshSubtitleSidebarSnapshot(); + mpvCommands.length = 0; + await modalListeners.get('mouseenter')?.[0]?.(); + + assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'yes']); + + modal.closeSubtitleSidebarModal(); + + assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'no']); + assert.equal(state.subtitleSidebarPausedByHover, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('subtitle sidebar hover pause ignores playback-state IPC failures', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const mpvCommands: Array> = []; + const modalListeners = new Map Promise | 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: true, + autoScroll: true, + maxWidth: 420, + 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: { + addEventListener: () => {}, + removeEventListener: () => {}, + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + getPlaybackPaused: async () => { + throw new Error('ipc failed'); + }, + sendMpvCommand: (command: Array) => { + mpvCommands.push(command); + }, + } as unknown as ElectronAPI, + }, + }); + 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: () => Promise | void) => { + const bucket = modalListeners.get(type) ?? []; + bucket.push(listener); + modalListeners.set(type, bucket); + }, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + modal.wireDomEvents(); + + await modal.openSubtitleSidebarModal(); + await assert.doesNotReject(async () => { + await modalListeners.get('mouseenter')?.[0]?.(); + }); + + assert.equal(state.subtitleSidebarPausedByHover, false); + assert.equal( + mpvCommands.some((command) => command[0] === 'set_property' && command[2] === 'yes'), + false, + ); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; @@ -412,6 +1361,7 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', currentTimeSec: 1.1, config: { enabled: true, + autoOpen: false, layout: 'embedded', toggleKey: 'Backslash', pauseVideoOnHover: false, @@ -485,6 +1435,9 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', subtitleSidebarStatus: { textContent: '' }, subtitleSidebarList: cueList, }, + platform: { + shouldToggleMouseIgnore: false, + }, state, }; @@ -502,6 +1455,30 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', command[2] === 0.3, ), ); + assert.ok( + mpvCommands.some( + (command) => + command[0] === 'set_property' && + command[1] === 'osd-align-x' && + command[2] === 'left', + ), + ); + assert.ok( + mpvCommands.some( + (command) => + command[0] === 'set_property' && + command[1] === 'osd-align-y' && + command[2] === 'top', + ), + ); + assert.ok( + mpvCommands.some( + (command) => + command[0] === 'set_property' && + command[1] === 'user-data/osc/margins' && + command[2] === '{"l":0,"r":0.3,"t":0,"b":0}', + ), + ); assert.ok(bodyClassList.contains('subtitle-sidebar-embedded-open')); assert.ok( rootStyleCalls.some( @@ -511,7 +1488,10 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', modal.closeSubtitleSidebarModal(); - assert.deepEqual(mpvCommands.at(-2), ['set_property', 'video-margin-ratio-right', 0]); + assert.deepEqual(mpvCommands.at(-5), ['set_property', 'video-margin-ratio-right', 0]); + assert.deepEqual(mpvCommands.at(-4), ['set_property', 'osd-align-x', 'left']); + assert.deepEqual(mpvCommands.at(-3), ['set_property', 'osd-align-y', 'top']); + assert.deepEqual(mpvCommands.at(-2), ['set_property', 'user-data/osc/margins', '{"l":0,"r":0,"t":0,"b":0}']); assert.deepEqual(mpvCommands.at(-1), ['set_property', 'video-pan-x', 0]); assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false); assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']); @@ -521,6 +1501,237 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', } }); +test('subtitle sidebar embedded 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 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: 'embedded', + 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 }), + }, + 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 }]); + + modalListeners.get('mouseenter')?.[0]?.(); + assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]); + + modalListeners.get('mouseleave')?.[0]?.(); + assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]); + + state.isOverSubtitle = true; + modalListeners.get('mouseenter')?.[0]?.(); + modalListeners.get('mouseleave')?.[0]?.(); + assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]); + + void mpvCommands; + } 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; + const previousDocument = globals.document; + const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; + + 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: 'embedded', + 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 ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: createClassList(['hidden']), + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 360 }), + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + platform: { + shouldToggleMouseIgnore: true, + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + + await modal.openSubtitleSidebarModal(); + state.isOverSubtitle = true; + modal.closeSubtitleSidebarModal(); + assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]); + + await modal.openSubtitleSidebarModal(); + state.isOverSubtitle = false; + modal.closeSubtitleSidebarModal(); + 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('subtitle sidebar resets embedded mpv margin on startup while closed', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; @@ -537,6 +1748,7 @@ test('subtitle sidebar resets embedded mpv margin on startup while closed', asyn currentTimeSec: 1.1, config: { enabled: true, + autoOpen: false, layout: 'embedded', toggleKey: 'Backslash', pauseVideoOnHover: false, @@ -613,6 +1825,9 @@ test('subtitle sidebar resets embedded mpv margin on startup while closed', asyn assert.deepEqual(mpvCommands, [ ['set_property', 'video-margin-ratio-right', 0], + ['set_property', 'osd-align-x', 'left'], + ['set_property', 'osd-align-y', 'top'], + ['set_property', 'user-data/osc/margins', '{"l":0,"r":0,"t":0,"b":0}'], ['set_property', 'video-pan-x', 0], ]); } finally { diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 3272bbd..745c146 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -4,6 +4,7 @@ import type { SubtitleSidebarSnapshot, } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; +import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; const MANUAL_SCROLL_HOLD_MS = 1500; const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18; @@ -11,7 +12,6 @@ const CLICK_SEEK_OFFSET_SEC = 0.08; const SNAPSHOT_POLL_INTERVAL_MS = 80; const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240; const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45; - function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean { if (a.length !== b.length) { return false; @@ -107,7 +107,10 @@ export function findActiveSubtitleCueIndex( if (forwardMatches.length > 0) { return forwardMatches[0]!; } - return preferredCueIndex; + if (matchingIndices.includes(preferredCueIndex)) { + return preferredCueIndex; + } + return matchingIndices[matchingIndices.length - 1] ?? -1; } let nearestIndex = matchingIndices[0]!; @@ -131,8 +134,13 @@ export function createSubtitleSidebarModal( modalStateReader: Pick; }, ) { - let snapshotPollInterval: ReturnType | null = null; + let snapshotPollInterval: ReturnType | null = null; let lastAppliedVideoMarginRatio: number | null = null; + let subtitleSidebarHoverRequestId = 0; + + function restoreEmbeddedSidebarPassthrough(): void { + syncOverlayMouseIgnoreState(ctx); + } function setStatus(message: string): void { ctx.dom.subtitleSidebarStatus.textContent = message; @@ -190,6 +198,26 @@ export function createSubtitleSidebarModal( 'video-margin-ratio-right', Number(ratio.toFixed(4)), ]); + window.electronAPI.sendMpvCommand([ + 'set_property', + 'osd-align-x', + 'left', + ]); + window.electronAPI.sendMpvCommand([ + 'set_property', + 'osd-align-y', + 'top', + ]); + window.electronAPI.sendMpvCommand([ + 'set_property', + 'user-data/osc/margins', + JSON.stringify({ + l: 0, + r: Number(ratio.toFixed(4)), + t: 0, + b: 0, + }), + ]); if (ratio === 0) { window.electronAPI.sendMpvCommand(['set_property', 'video-pan-x', 0]); } @@ -228,6 +256,22 @@ export function createSubtitleSidebarModal( ]); } + function getCueRowLabel(cue: SubtitleCue): string { + return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`; + } + + function resumeSubtitleSidebarHoverPause(): void { + subtitleSidebarHoverRequestId += 1; + if (!ctx.state.subtitleSidebarPausedByHover) { + restoreEmbeddedSidebarPassthrough(); + return; + } + + ctx.state.subtitleSidebarPausedByHover = false; + window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']); + restoreEmbeddedSidebarPassthrough(); + } + function maybeAutoScrollActiveCue( previousActiveCueIndex: number, behavior: ScrollBehavior = 'smooth', @@ -250,8 +294,14 @@ export function createSubtitleSidebarModal( const targetScrollTop = active.offsetTop - (list.clientHeight - active.clientHeight) / 2; + const nextScrollTop = Math.max(0, targetScrollTop); + if (previousActiveCueIndex < 0) { + list.scrollTop = nextScrollTop; + return; + } + list.scrollTo({ - top: Math.max(0, targetScrollTop), + top: nextScrollTop, behavior, }); } @@ -263,6 +313,16 @@ export function createSubtitleSidebarModal( row.className = 'subtitle-sidebar-item'; row.classList.toggle('active', index === ctx.state.subtitleSidebarActiveCueIndex); row.dataset.index = String(index); + row.tabIndex = 0; + row.setAttribute('role', 'button'); + row.setAttribute('aria-label', getCueRowLabel(cue)); + row.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + seekToCue(cue); + }); const timestamp = document.createElement('div'); timestamp.className = 'subtitle-sidebar-timestamp'; @@ -315,6 +375,23 @@ export function createSubtitleSidebarModal( async function refreshSnapshot(): Promise { const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot(); applyConfig(snapshot); + if (!snapshot.config.enabled) { + resumeSubtitleSidebarHoverPause(); + ctx.state.subtitleSidebarCues = []; + ctx.state.subtitleSidebarModalOpen = false; + ctx.dom.subtitleSidebarModal.classList.add('hidden'); + ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true'); + stopSnapshotPolling(); + updateActiveCue(null, snapshot.currentTimeSec ?? null); + setStatus('Subtitle sidebar disabled in config.'); + syncEmbeddedSidebarLayout(); + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + restoreEmbeddedSidebarPassthrough(); + return snapshot; + } + const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues); if (cuesChanged) { ctx.state.subtitleSidebarCues = snapshot.cues; @@ -328,34 +405,50 @@ export function createSubtitleSidebarModal( } function startSnapshotPolling(): void { - if (snapshotPollInterval) { - clearInterval(snapshotPollInterval); - } - snapshotPollInterval = setInterval(() => { - void refreshSnapshot(); - }, SNAPSHOT_POLL_INTERVAL_MS); + stopSnapshotPolling(); + + const pollOnce = async (): Promise => { + try { + await refreshSnapshot(); + } catch { + // Keep polling; a transient IPC failure should not stop updates. + } + + if (!ctx.state.subtitleSidebarModalOpen) { + snapshotPollInterval = null; + return; + } + + snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS); + }; + + snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS); } function stopSnapshotPolling(): void { if (!snapshotPollInterval) { return; } - clearInterval(snapshotPollInterval); + clearTimeout(snapshotPollInterval); snapshotPollInterval = null; } async function openSubtitleSidebarModal(): Promise { const snapshot = await refreshSnapshot(); - ctx.dom.subtitleSidebarList.innerHTML = ''; if (!snapshot.config.enabled) { setStatus('Subtitle sidebar disabled in config.'); - } else if (snapshot.cues.length === 0) { + return; + } + + ctx.dom.subtitleSidebarList.innerHTML = ''; + if (snapshot.cues.length === 0) { setStatus('No parsed subtitle cues available.'); } else { setStatus(`${snapshot.cues.length} parsed subtitle lines`); } ctx.state.subtitleSidebarModalOpen = true; + ctx.state.isOverSubtitleSidebar = false; ctx.dom.subtitleSidebarModal.classList.remove('hidden'); ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false'); renderCueList(); @@ -363,12 +456,23 @@ export function createSubtitleSidebarModal( maybeAutoScrollActiveCue(-1, 'auto', true); startSnapshotPolling(); syncEmbeddedSidebarLayout(); + restoreEmbeddedSidebarPassthrough(); + } + + async function autoOpenSubtitleSidebarOnStartup(): Promise { + const snapshot = await refreshSnapshot(); + if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) { + return; + } + await openSubtitleSidebarModal(); } function closeSubtitleSidebarModal(): void { if (!ctx.state.subtitleSidebarModalOpen) { return; } + resumeSubtitleSidebarHoverPause(); + ctx.state.isOverSubtitleSidebar = false; ctx.state.subtitleSidebarModalOpen = false; ctx.dom.subtitleSidebarModal.classList.add('hidden'); ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true'); @@ -377,6 +481,7 @@ export function createSubtitleSidebarModal( if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } + restoreEmbeddedSidebarPassthrough(); } async function toggleSubtitleSidebarModal(): Promise { @@ -425,21 +530,29 @@ export function createSubtitleSidebarModal( ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS; }); ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => { + ctx.state.isOverSubtitleSidebar = true; + restoreEmbeddedSidebarPassthrough(); if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) { return; } - const paused = await window.electronAPI.getPlaybackPaused(); + const requestId = ++subtitleSidebarHoverRequestId; + let paused: boolean | null | undefined; + try { + paused = await window.electronAPI.getPlaybackPaused(); + } catch { + paused = undefined; + } + if (requestId !== subtitleSidebarHoverRequestId) { + return; + } if (paused === false) { window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']); ctx.state.subtitleSidebarPausedByHover = true; } }); ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => { - if (!ctx.state.subtitleSidebarPausedByHover) { - return; - } - ctx.state.subtitleSidebarPausedByHover = false; - window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']); + ctx.state.isOverSubtitleSidebar = false; + resumeSubtitleSidebarHoverPause(); }); window.addEventListener('resize', () => { if (!ctx.state.subtitleSidebarModalOpen) { @@ -450,6 +563,7 @@ export function createSubtitleSidebarModal( } return { + autoOpenSubtitleSidebarOnStartup, openSubtitleSidebarModal, closeSubtitleSidebarModal, toggleSubtitleSidebarModal, diff --git a/src/renderer/style.css b/src/renderer/style.css index 699f0ac..2192fd0 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -723,6 +723,7 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer { #secondarySubContainer { --secondary-sub-background-color: transparent; --secondary-sub-backdrop-filter: none; + position: absolute; top: 40px; left: 50%; @@ -779,7 +780,11 @@ body.settings-modal-open #secondarySubContainer { } body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover { - transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5)); + left: 0; + right: var(--subtitle-sidebar-reserved-width); + max-width: none; + padding-right: 0; + transform: none; } #secondarySubContainer.secondary-sub-hover { @@ -808,11 +813,13 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover { padding: 10px 18px; } -#secondarySubContainer.secondary-sub-hover:hover { +#secondarySubContainer.secondary-sub-hover:hover, +#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active { opacity: 1; } -#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot { +#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot, +#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot { background: var(--secondary-sub-background-color, transparent); backdrop-filter: var(--secondary-sub-backdrop-filter, none); -webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none); @@ -1490,7 +1497,6 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { min-height: 0; border-radius: 0; background: transparent; - scroll-behavior: smooth; } .subtitle-sidebar-list::-webkit-scrollbar { @@ -1531,6 +1537,12 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65)); } +.subtitle-sidebar-item:focus-visible { + outline: 2px solid var(--subtitle-sidebar-active-line-color, #f5bde6); + outline-offset: -2px; + background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65)); +} + .subtitle-sidebar-item.active { background: var(--subtitle-sidebar-active-background-color, rgba(138, 173, 244, 0.12)); }