Files
SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
T
sudacode 4d27e068c6 fix(subtitles): follow sub-delay in sidebar, navigation, and overlay matching
Anything comparing time-pos with parsed cues now converts through a
shared subtitle-clock helper: the sidebar highlight and click-to-seek,
the sub-seek cue sanitizer, overlay/primer line resolution, and the
replay pause point after sub-seek.
2026-10-25 21:40:02 -07:00

994 lines
33 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type {
ElectronAPI,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
} from '../../types';
import { createRendererState } from '../state.js';
import {
applySidebarCssDeclarations,
createSubtitleSidebarModal,
findActiveSubtitleCueIndex,
} from './subtitle-sidebar.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
type SidebarConfig = SubtitleSidebarSnapshot['config'];
type SidebarModalOptions = Parameters<typeof createSubtitleSidebarModal>[1];
type MpvCommand = Array<string | number>;
type Listener = (event?: unknown) => unknown;
type ClassList = ReturnType<typeof createClassList>;
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
function createCueRow() {
const listeners = new Map<string, Array<(event: unknown) => void>>();
return {
className: '',
classList: createClassList(),
dataset: {} as Record<string, string>,
textContent: '',
tabIndex: -1,
offsetTop: 0,
clientHeight: 40,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
attributes: {} as Record<string, string>,
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 ?? {});
},
};
}
function createListStub() {
return {
get innerHTML() {
return '';
},
set innerHTML(_html: string) {
this.children.length = 0;
},
children: [] as ReturnType<typeof createCueRow>[],
appendChild(child: ReturnType<typeof createCueRow>) {
child.offsetTop = this.children.length * child.clientHeight;
this.children.push(child);
},
addEventListener: () => {},
scrollTop: 0,
clientHeight: 240,
scrollHeight: 480,
scrollToCalls: [] as ScrollToOptions[],
scrollTo(options?: ScrollToOptions) {
this.scrollToCalls.push(options ?? {});
},
};
}
function createListenerTarget() {
const listeners = new Map<string, Listener[]>();
return {
addEventListener: (type: string, listener: Listener) => {
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
},
removeEventListener: () => {},
count: (type: string) => listeners.get(type)?.length ?? 0,
async dispatch(type: string, event?: unknown) {
for (const listener of listeners.get(type) ?? []) {
await listener(event);
}
},
};
}
/** Replaces globals and returns a function that puts the previous descriptors back. */
function installGlobals(values: Record<string, unknown>): () => void {
const previous = Object.keys(values).map(
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
);
for (const [key, value] of Object.entries(values)) {
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
return () => {
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
};
}
/** Folds `set_property` commands into the final value mpv would hold for each property. */
function mpvPropertyState(commands: MpvCommand[]): Map<string, string | number> {
const properties = new Map<string, string | number>();
for (const [command, name, value] of commands) {
if (command === 'set_property' && typeof name === 'string' && value !== undefined) {
properties.set(name, value);
}
}
return properties;
}
const RELEASED_EMBEDDED_MARGIN = new Map<string, string | number>([
['video-margin-ratio-right', 0],
['osd-align-x', 'left'],
['osd-align-y', 'top'],
['user-data/osc/margins', '{"l":0,"r":0,"t":0,"b":0}'],
['video-pan-x', 0],
]);
const DEFAULT_SIDEBAR_CONFIG: SidebarConfig = {
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)',
};
interface SidebarHarnessOptions {
config?: Partial<SidebarConfig>;
cues?: SubtitleSidebarSnapshot['cues'];
currentSubtitle?: SubtitleSidebarSnapshot['currentSubtitle'];
currentTimeSec?: number | null;
/** `ctx.platform.shouldToggleMouseIgnore`, i.e. macOS/Windows window passthrough. */
toggleMouseIgnore?: boolean;
/** Panel width from getBoundingClientRect; a function sees the panel class list. */
contentWidth?: number | ((classList: ClassList) => number);
electronAPI?: Partial<ElectronAPI>;
modalOptions?: Partial<SidebarModalOptions>;
}
type SnapshotPatch = Partial<Omit<SubtitleSidebarSnapshot, 'config'>> & {
config?: Partial<SidebarConfig>;
};
const harnessCleanups: Array<() => void> = [];
test.afterEach(() => {
for (const cleanup of harnessCleanups.splice(0).reverse()) cleanup();
});
/**
* Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the
* modal (and the playback state derived from it) can be changed with `setSnapshot`; `poll`
* runs the next scheduled playback tick. Globals are restored after each test.
*/
function createSidebarHarness(options: SidebarHarnessOptions = {}) {
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cuesRevision: 1,
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
subDelay: 0,
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
};
const requests = { snapshot: 0, playback: 0 };
let nextPoll: (() => Promise<void>) | null = null;
const mpvCommands: MpvCommand[] = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const modalNotifications: string[] = [];
const visibilityChanges: boolean[] = [];
const rootStyle = new Map<string, string>();
const bodyClassList = createClassList();
const windowEvents = createListenerTarget();
const contentEvents = createListenerTarget();
const modalEvents = createListenerTarget();
const restoreGlobals = installGlobals({
window: {
innerWidth: 1200,
addEventListener: windowEvents.addEventListener,
removeEventListener: windowEvents.removeEventListener,
electronAPI: {
getSubtitleSidebarSnapshot: async () => {
requests.snapshot += 1;
return snapshot;
},
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
requests.playback += 1;
const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot;
return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle };
},
getPlaybackPaused: async () => false,
sendMpvCommand: (command: MpvCommand) => {
mpvCommands.push(command);
},
setIgnoreMouseEvents: (ignore: boolean, forward?: { forward?: boolean }) => {
ignoreMouseCalls.push([ignore, forward]);
},
notifyOverlayModalOpened: (modal: string) => {
modalNotifications.push(`open:${modal}`);
},
notifyOverlayModalClosed: (modal: string) => {
modalNotifications.push(`close:${modal}`);
},
...options.electronAPI,
},
},
document: {
createElement: () => createCueRow(),
body: { classList: bodyClassList },
documentElement: {
style: {
setProperty: (name: string, value: string) => {
rootStyle.set(name, value);
},
},
},
},
});
const contentClassList = createClassList();
const contentWidth = options.contentWidth ?? 420;
const contentStyle: Record<string, string> = {};
const dom = {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: modalEvents.addEventListener,
},
subtitleSidebarContent: {
classList: contentClassList,
style: {
setProperty: (name: string, value: string) => {
contentStyle[name] = value;
},
removeProperty: (name: string) => {
delete contentStyle[name];
},
},
getBoundingClientRect: () => ({
width: typeof contentWidth === 'function' ? contentWidth(contentClassList) : contentWidth,
}),
addEventListener: contentEvents.addEventListener,
contains: () => false,
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
};
const state = createRendererState();
const ctx = {
dom,
platform: { shouldToggleMouseIgnore: options.toggleMouseIgnore ?? false },
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
onVisibilityChanged: (visible) => {
visibilityChanges.push(visible);
},
schedule: (callback) => {
nextPoll = callback;
return () => {
if (nextPoll === callback) nextPoll = null;
};
},
...options.modalOptions,
});
harnessCleanups.push(() => {
modal.closeSubtitleSidebarModal();
modal.disposeDomEvents();
restoreGlobals();
});
return {
modal,
state,
dom,
cueList: dom.subtitleSidebarList,
modalClassList: dom.subtitleSidebarModal.classList,
contentStyle,
mpvCommands,
ignoreMouseCalls,
modalNotifications,
visibilityChanges,
rootStyle,
bodyClassList,
windowEvents,
contentEvents,
modalEvents,
requests,
/** Runs the pending playback poll tick, if one is scheduled. */
async poll() {
const tick = nextPoll;
nextPoll = null;
await tick?.();
},
setSnapshot(patch: SnapshotPatch) {
snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } };
},
};
}
test('findActiveSubtitleCueIndex prefers timing match before text fallback', () => {
const cues = [
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 3, endTime: 4, text: 'same' },
];
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: 3.1 }), 1);
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: null }), 0);
});
test('findActiveSubtitleCueIndex prefers current subtitle timing over near-future clock lookahead', () => {
const cues = [
{ startTime: 231, endTime: 233.2, text: 'previous' },
{ startTime: 233.05, endTime: 236, text: 'next' },
];
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
});
test('findActiveSubtitleCueIndex follows playback through empty subtitle gaps', () => {
const cues = [
{ startTime: 0, endTime: 2, text: 'first' },
{ startTime: 100, endTime: 102, text: 'later' },
{ startTime: 105, endTime: 107, text: 'next' },
];
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'later', startTime: 100 }, 101, 1), 1);
assert.equal(findActiveSubtitleCueIndex(cues, { text: '', startTime: 0 }, 103, 1), 2);
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'next', startTime: 105 }, 105, 2), 2);
assert.equal(findActiveSubtitleCueIndex(cues, { text: '', startTime: 0 }, 108, 2), -1);
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'first', startTime: 0 }, 0, 2), 0);
});
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 mining context resolves selected row cue timing', () => {
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
dataset: Record<string, string> = {};
closest(selector: string) {
return selector === '.subtitle-sidebar-item' ? this : null;
}
}
const row = new FakeElement();
row.dataset.index = '1';
const textNode = new FakeNode();
textNode.parentElement = row;
const restoreGlobals = installGlobals({
Node: FakeNode,
Element: FakeElement,
window: { getSelection: () => ({ anchorNode: textNode, focusNode: null }) },
});
try {
const state = createRendererState();
state.subtitleSidebarModalOpen = true;
state.subtitleSidebarCues = [
{ startTime: 1, endTime: 2, text: 'current line' },
{ startTime: 3, endTime: 5, text: 'sidebar previous line' },
];
const modal = createSubtitleSidebarModal(
{
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
style: { setProperty: () => {} },
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
},
);
const context = modal.getSubtitleSidebarMiningContext();
assert.equal(context?.source, 'subtitle-sidebar');
assert.equal(context?.text, 'sidebar previous line');
assert.equal(context?.startTime, 3);
assert.equal(context?.endTime, 5);
assert.equal(typeof context?.capturedAtMs, 'number');
} finally {
restoreGlobals();
}
});
test('applySidebarCssDeclarations clears declarations removed by config reload', () => {
const removed: string[] = [];
const style = {
color: '',
backgroundColor: '',
setProperty(property: string, value: string) {
(this as unknown as Record<string, string>)[property] = value;
},
removeProperty(property: string) {
removed.push(property);
delete (this as unknown as Record<string, string>)[property];
},
};
const target = { style } as unknown as HTMLElement;
applySidebarCssDeclarations(target, {
color: '#cad3f5',
'background-color': '#181926',
});
applySidebarCssDeclarations(target, {
color: '#ffffff',
});
assert.equal(style.color, '#ffffff');
assert.equal(style.backgroundColor, '');
assert.deepEqual(removed, ['background-color']);
applySidebarCssDeclarations(target, {
color: '',
'background-color': '',
});
assert.equal(style.color, '');
assert.deepEqual(removed, ['background-color', 'background-color']);
});
const overlappingCues = [
{ startTime: 1, endTime: 3.4, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
];
test('subtitle sidebar opens from snapshot with the current cue active and config css applied', async () => {
const h = createSidebarHarness({
cues: overlappingCues,
currentSubtitle: { text: 'second', startTime: 3.5, endTime: 4 },
currentTimeSec: 3.5,
config: { css: { 'font-size': '22px' } },
});
await h.modal.openSubtitleSidebarModal();
assert.equal(h.state.subtitleSidebarModalOpen, true);
assert.equal(h.modalClassList.contains('hidden'), false);
assert.equal(h.state.subtitleSidebarActiveCueIndex, 1);
assert.equal(h.cueList.children.length, 2);
assert.equal(h.cueList.scrollTop, 0);
assert.deepEqual(h.cueList.scrollToCalls, []);
assert.equal(h.contentStyle['font-size'], '22px');
assert.deepEqual(h.visibilityChanges, [true]);
assert.deepEqual(h.modalNotifications, ['open:subtitle-sidebar']);
});
test('subtitle sidebar seeks to the selected cue start using the overlap-aware seek time', async () => {
const h = createSidebarHarness({ cues: overlappingCues });
await h.modal.openSubtitleSidebarModal();
h.modal.seekToCue(overlappingCues[0]!);
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
h.modal.seekToCue(overlappingCues[1]!);
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
});
test('subtitle sidebar seeks to where mpv plays a sub-delay retimed cue', async () => {
const h = createSidebarHarness();
h.setSnapshot({ subDelay: 2 });
await h.modal.openSubtitleSidebarModal();
h.modal.seekToCue({ startTime: 1, endTime: 2, text: 'first' });
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.08, 'absolute+exact']);
});
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal();
h.modal.closeSubtitleSidebarModal();
assert.equal(h.state.subtitleSidebarModalOpen, false);
assert.equal(h.modalClassList.contains('hidden'), true);
assert.deepEqual(h.visibilityChanges, [true, false]);
assert.deepEqual(h.modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
});
test('subtitle sidebar rows seek with Enter and leave Space to playback shortcuts', async () => {
const h = createSidebarHarness({
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
currentSubtitle: { text: 'second', startTime: 3, endTime: 4 },
currentTimeSec: 3.1,
});
await h.modal.openSubtitleSidebarModal();
const keydown = h.cueList.children[0]!.listeners.get('keydown')?.[0];
assert.ok(keydown);
h.mpvCommands.length = 0;
let spacePrevented = false;
keydown({
key: ' ',
preventDefault: () => {
spacePrevented = true;
},
});
assert.deepEqual(h.mpvCommands, []);
assert.equal(spacePrevented, false);
keydown({ key: 'Enter', preventDefault: () => {} });
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
});
test('subtitle sidebar renders hour-long cue timestamps as HH:MM:SS', async () => {
const h = createSidebarHarness({
cues: [{ startTime: 3665, endTime: 3670, text: 'long cue' }],
currentSubtitle: { text: 'long cue', startTime: 3665, endTime: 3670 },
currentTimeSec: 3665,
});
await h.modal.openSubtitleSidebarModal();
const firstRow = h.cueList.children[0]!;
assert.equal(firstRow.attributes['aria-label'], 'Jump to subtitle at 01:01:05');
assert.equal((firstRow.children[0] as { textContent: string }).textContent, '01:01:05');
});
test('subtitle sidebar does not open when the feature is disabled', async () => {
const h = createSidebarHarness({ config: { enabled: false } });
await h.modal.openSubtitleSidebarModal();
assert.equal(h.state.subtitleSidebarModalOpen, false);
assert.equal(h.modalClassList.contains('hidden'), true);
assert.equal(h.cueList.children.length, 0);
assert.equal(h.dom.subtitleSidebarStatus.textContent, 'Subtitle sidebar disabled in config.');
});
test('subtitle sidebar auto-open on startup only opens when enabled and configured', async () => {
const h = createSidebarHarness({ config: { autoOpen: true } });
await h.modal.autoOpenSubtitleSidebarOnStartup();
assert.equal(h.state.subtitleSidebarModalOpen, true);
assert.equal(h.modalClassList.contains('hidden'), false);
assert.equal(h.cueList.children.length, 1);
h.modal.closeSubtitleSidebarModal();
h.setSnapshot({ config: { autoOpen: false } });
await h.modal.autoOpenSubtitleSidebarOnStartup();
assert.equal(h.state.subtitleSidebarModalOpen, false);
});
test('subtitle sidebar auto-open restores previously open sidebar after renderer replacement', async () => {
const h = createSidebarHarness({
modalOptions: { shouldRestoreOpenOnStartup: async () => true },
});
await h.modal.autoOpenSubtitleSidebarOnStartup();
assert.equal(h.state.subtitleSidebarModalOpen, true);
assert.equal(h.modalClassList.contains('hidden'), false);
assert.equal(h.cueList.children.length, 1);
});
test('subtitle sidebar refresh closes and clears state when config becomes disabled', async () => {
const h = createSidebarHarness({
config: { layout: 'embedded', maxWidth: 360 },
contentWidth: 360,
});
await h.modal.openSubtitleSidebarModal();
assert.equal(h.state.subtitleSidebarModalOpen, true);
assert.equal(h.bodyClassList.contains('subtitle-sidebar-embedded-open'), true);
h.setSnapshot({
cues: [],
currentSubtitle: { text: '', startTime: null, endTime: null },
currentTimeSec: null,
config: { enabled: false },
});
await h.modal.refreshSubtitleSidebarSnapshot();
assert.equal(h.state.subtitleSidebarModalOpen, false);
assert.equal(h.state.subtitleSidebarCues.length, 0);
assert.equal(h.state.subtitleSidebarActiveCueIndex, -1);
assert.equal(h.modalClassList.contains('hidden'), true);
assert.equal(h.bodyClassList.contains('subtitle-sidebar-embedded-open'), false);
});
test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timing', async () => {
const h = createSidebarHarness({
cues: [
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 3, endTime: 4, text: 'other' },
{ startTime: 10, endTime: 11, text: 'same' },
],
currentSubtitle: { text: 'same', startTime: 10, endTime: 11 },
currentTimeSec: 10.1,
});
await h.modal.openSubtitleSidebarModal();
h.cueList.scrollToCalls.length = 0;
h.modal.handleSubtitleUpdated({ text: 'same', startTime: null, endTime: null, tokens: [] });
assert.equal(h.state.subtitleSidebarActiveCueIndex, 2);
assert.deepEqual(h.cueList.scrollToCalls, []);
});
test('subtitle sidebar does not regress to previous cue on text-only transition update', async () => {
const h = createSidebarHarness({
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
{ startTime: 5, endTime: 6, text: 'third' },
],
currentSubtitle: { text: 'third', startTime: 5, endTime: 6 },
currentTimeSec: 5.1,
});
await h.modal.openSubtitleSidebarModal();
h.cueList.scrollToCalls.length = 0;
h.modal.handleSubtitleUpdated({ text: 'second', startTime: null, endTime: null, tokens: [] });
assert.equal(h.state.subtitleSidebarActiveCueIndex, 2);
assert.deepEqual(h.cueList.scrollToCalls, []);
});
test('subtitle sidebar jumps to first resolved active cue, then resumes smooth auto-follow', async () => {
const h = createSidebarHarness({
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,
});
await h.modal.openSubtitleSidebarModal();
assert.equal(h.state.subtitleSidebarActiveCueIndex, -1);
h.cueList.scrollToCalls.length = 0;
h.setSnapshot({
currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 },
currentTimeSec: 18.1,
});
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 9);
assert.equal(h.cueList.scrollTop, 260);
assert.deepEqual(h.cueList.scrollToCalls, []);
h.setSnapshot({
currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 },
currentTimeSec: 20.1,
});
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 10);
assert.deepEqual(h.cueList.scrollToCalls.at(-1), { top: 300, behavior: 'smooth' });
});
test('open subtitle sidebar follows playback from cheap polls without refetching cues or re-measuring', async () => {
let measurements = 0;
const h = createSidebarHarness({
cues: Array.from({ length: 4 }, (_, index) => ({
startTime: index * 2,
endTime: index * 2 + 1.5,
text: `line-${index}`,
})),
currentSubtitle: { text: 'line-0', startTime: 0, endTime: 1.5 },
currentTimeSec: 0.1,
config: { layout: 'embedded', maxWidth: 360 },
contentWidth: () => {
measurements += 1;
return 360;
},
});
await h.modal.openSubtitleSidebarModal();
const snapshotsAfterOpen = h.requests.snapshot;
const measurementsAfterOpen = measurements;
// Inside the lookahead window the upcoming cue activates before mpv reports its text.
h.setSnapshot({
currentSubtitle: { text: '', startTime: null, endTime: null },
currentTimeSec: 1.9,
});
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 1);
h.setSnapshot({
currentSubtitle: { text: 'line-2', startTime: 4, endTime: 5.5 },
currentTimeSec: 4.1,
});
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 2);
assert.equal(h.cueList.children[2]!.classList.contains('active'), true);
assert.equal(h.cueList.children[1]!.classList.contains('active'), false);
assert.equal(h.requests.playback, 2);
assert.equal(h.requests.snapshot, snapshotsAfterOpen);
assert.equal(measurements, measurementsAfterOpen);
});
for (const change of [
{ name: 'subtitle source', patch: { sourceKey: 'other-subtitles' } },
{ name: 'parsed cue revision', patch: { cuesRevision: 2 } },
]) {
test(`open subtitle sidebar refetches and re-renders cues when the ${change.name} changes`, async () => {
const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal();
const snapshotsAfterOpen = h.requests.snapshot;
h.setSnapshot({
...change.patch,
cues: [
{ startTime: 1, endTime: 2, text: 'replacement' },
{ startTime: 3, endTime: 4, text: 'second' },
],
});
await h.poll();
assert.equal(h.requests.snapshot, snapshotsAfterOpen + 1);
assert.deepEqual(
h.cueList.children.map((row) => (row.children[1] as { textContent: string }).textContent),
['replacement', 'second'],
);
});
}
test('open subtitle sidebar picks up changes main did not flag on a slow full-snapshot fallback', async () => {
const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal();
h.setSnapshot({ config: { enabled: false } });
for (let polls = 0; h.state.subtitleSidebarModalOpen && polls < 100; polls += 1) {
await h.poll();
}
assert.equal(h.state.subtitleSidebarModalOpen, false);
assert.equal(h.requests.snapshot, 2);
assert.ok(h.requests.playback > 1);
});
test('subtitle sidebar closes and resumes a hover pause', async () => {
const h = createSidebarHarness({ config: { pauseVideoOnHover: true } });
h.modal.wireDomEvents();
await h.modal.openSubtitleSidebarModal();
await h.contentEvents.dispatch('mouseenter');
assert.equal(mpvPropertyState(h.mpvCommands).get('pause'), 'yes');
h.modal.closeSubtitleSidebarModal();
assert.equal(mpvPropertyState(h.mpvCommands).get('pause'), 'no');
assert.equal(h.state.subtitleSidebarPausedByHover, false);
});
test('subtitle sidebar hover pause ignores playback-state IPC failures', async () => {
const h = createSidebarHarness({
config: { pauseVideoOnHover: true },
electronAPI: {
getPlaybackPaused: async () => {
throw new Error('ipc failed');
},
},
});
h.modal.wireDomEvents();
await h.modal.openSubtitleSidebarModal();
await assert.doesNotReject(() => h.contentEvents.dispatch('mouseenter'));
assert.equal(h.state.subtitleSidebarPausedByHover, false);
assert.equal(mpvPropertyState(h.mpvCommands).has('pause'), false);
});
test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains open', async () => {
const h = createSidebarHarness({ config: { pauseVideoOnHover: true } });
h.state.autoPauseVideoOnYomitanPopup = true;
h.modal.wireDomEvents();
await h.modal.openSubtitleSidebarModal();
h.mpvCommands.length = 0;
await h.contentEvents.dispatch('mouseenter');
assert.deepEqual(h.mpvCommands, [['set_property', 'pause', 'yes']]);
await h.windowEvents.dispatch(YOMITAN_POPUP_SHOWN_EVENT);
await h.contentEvents.dispatch('mouseleave');
assert.deepEqual(h.mpvCommands, [['set_property', 'pause', 'yes']]);
assert.equal(h.state.subtitleSidebarPausedByHover, true);
await h.windowEvents.dispatch(YOMITAN_POPUP_HIDDEN_EVENT);
assert.deepEqual(h.mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.equal(h.state.subtitleSidebarPausedByHover, false);
});
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
const h = createSidebarHarness({
config: { layout: 'embedded', maxWidth: 360 },
contentWidth: 360,
});
await h.modal.openSubtitleSidebarModal();
assert.deepEqual(
mpvPropertyState(h.mpvCommands),
new Map<string, string | number>([
['video-margin-ratio-right', 0.3],
['osd-align-x', 'left'],
['osd-align-y', 'top'],
['user-data/osc/margins', '{"l":0,"r":0.3,"t":0,"b":0}'],
['video-pan-x', 0],
]),
);
assert.ok(h.bodyClassList.contains('subtitle-sidebar-embedded-open'));
assert.equal(h.rootStyle.get('--subtitle-sidebar-reserved-width'), '360px');
h.mpvCommands.length = 0;
h.modal.closeSubtitleSidebarModal();
assert.deepEqual(mpvPropertyState(h.mpvCommands), RELEASED_EMBEDDED_MARGIN);
assert.equal(h.bodyClassList.contains('subtitle-sidebar-embedded-open'), false);
assert.equal(h.rootStyle.get('--subtitle-sidebar-reserved-width'), '0px');
});
test('subtitle sidebar embedded layout measures reserved width after embedded classes apply', async () => {
const h = createSidebarHarness({
config: { layout: 'embedded' },
contentWidth: (classList) =>
classList.contains('subtitle-sidebar-content-embedded') ? 300 : 0,
});
await h.modal.openSubtitleSidebarModal();
assert.ok(h.bodyClassList.contains('subtitle-sidebar-embedded-open'));
assert.equal(h.rootStyle.get('--subtitle-sidebar-reserved-width'), '300px');
assert.equal(mpvPropertyState(h.mpvCommands).get('video-margin-ratio-right'), 0.25);
});
test('subtitle sidebar resets embedded mpv margin on startup while closed', async () => {
const h = createSidebarHarness({
config: { layout: 'embedded', maxWidth: 360 },
contentWidth: 360,
});
await h.modal.refreshSubtitleSidebarSnapshot();
assert.deepEqual(mpvPropertyState(h.mpvCommands), RELEASED_EMBEDDED_MARGIN);
});
for (const layout of ['overlay', 'embedded'] as const) {
test(`subtitle sidebar ${layout} layout restores macOS and Windows passthrough outside sidebar hover`, async () => {
const h = createSidebarHarness({
config: { layout, maxWidth: 360 },
contentWidth: 360,
toggleMouseIgnore: true,
});
h.modal.wireDomEvents();
// Hover is tracked on the panel, not on the full-window modal backdrop.
assert.equal(h.modalEvents.count('mouseenter'), 0);
assert.equal(h.modalEvents.count('mouseleave'), 0);
assert.equal(h.contentEvents.count('mouseenter'), 1);
assert.equal(h.contentEvents.count('mouseleave'), 1);
await h.modal.openSubtitleSidebarModal();
assert.deepEqual(h.ignoreMouseCalls.at(-1), [true, { forward: true }]);
await h.contentEvents.dispatch('mouseenter');
assert.deepEqual(h.ignoreMouseCalls.at(-1), [false, undefined]);
await h.contentEvents.dispatch('mouseleave');
assert.deepEqual(h.ignoreMouseCalls.at(-1), [true, { forward: true }]);
h.state.isOverSubtitle = true;
await h.contentEvents.dispatch('mouseenter');
await h.contentEvents.dispatch('mouseleave');
assert.deepEqual(h.ignoreMouseCalls.at(-1), [false, undefined]);
});
}
test('subtitle sidebar overlay layout only stays interactive while focus remains inside the sidebar panel', async () => {
const h = createSidebarHarness({ toggleMouseIgnore: true });
h.modal.wireDomEvents();
await h.modal.openSubtitleSidebarModal();
assert.deepEqual(h.ignoreMouseCalls.at(-1), [true, { forward: true }]);
await h.contentEvents.dispatch('focusin');
assert.deepEqual(h.ignoreMouseCalls.at(-1), [false, undefined]);
await h.contentEvents.dispatch('focusout', { relatedTarget: null });
assert.deepEqual(h.ignoreMouseCalls.at(-1), [true, { forward: true }]);
});
test('closing embedded subtitle sidebar recomputes passthrough from remaining subtitle hover state', async () => {
const h = createSidebarHarness({
config: { layout: 'embedded', maxWidth: 360 },
contentWidth: 360,
toggleMouseIgnore: true,
});
await h.modal.openSubtitleSidebarModal();
h.state.isOverSubtitle = true;
h.modal.closeSubtitleSidebarModal();
assert.deepEqual(h.ignoreMouseCalls.at(-1), [false, undefined]);
await h.modal.openSubtitleSidebarModal();
h.state.isOverSubtitle = false;
h.modal.closeSubtitleSidebarModal();
assert.deepEqual(h.ignoreMouseCalls.at(-1), [true, { forward: true }]);
});