fix(overlay): poll cheap playback state in the open subtitle sidebar (#290)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-11 00:35:27 -07:00
1 parent 7108e086ba
commit a6d96e9966
14 files changed
+286 -108

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback.
+5 -1
View File
@@ -63,7 +63,11 @@ the plugin artifact, persistent config, or session help.
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey` The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
identifies the media and subtitle source so renderer selections are invalidated on source changes, identifies the media and subtitle source so renderer selections are invalidated on source changes,
including changes whose cue text and timings are identical. Native selection and clean clipboard including changes whose cue text and timings are identical. While open, the sidebar polls the
cheap `SubtitleSidebarPlaybackState` (cached time, current line, `sourceKey`, `cuesRevision`; no
mpv round-trips) every 80 ms for active-cue tracking. It fetches the full snapshot, which resolves
the source over mpv IPC and carries every cue plus config, only on open, config reload, a
`sourceKey`/`cuesRevision` change, and a ~2 s fallback. Native selection and clean clipboard
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding. Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
The preload bridge writes selections through Electron's clipboard API so copying does not depend The preload bridge writes selections through Electron's clipboard API so copying does not depend
+28
View File
@@ -156,6 +156,34 @@ test(
}, },
); );
test(
'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
await showCue(FIXTURE_CUES[0]);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
{ description: 'sidebar to list every fixture cue' },
);
for (const [index, cue] of FIXTURE_CUES.entries()) {
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
{ description: `sidebar to highlight cue ${index}` },
);
}
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
},
);
// Runs last: it leaves the lookup popup open over the overlay. // Runs last: it leaves the lookup popup open over the overlay.
test( test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry', 'holding Shift over a word opens the Yomitan popup with its dictionary entry',
+2
View File
@@ -86,7 +86,9 @@ function createControllerConfigFixture() {
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot { function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return { return {
sourceKey: 'test-subtitles', sourceKey: 'test-subtitles',
cuesRevision: 1,
cues: [], cues: [],
currentTimeSec: null,
currentSubtitle: { text: '', startTime: null, endTime: null }, currentSubtitle: { text: '', startTime: null, endTime: null },
config: { config: {
enabled: false, enabled: false,
+11
View File
@@ -12,6 +12,7 @@ import type {
RuntimeOptionId, RuntimeOptionId,
RuntimeOptionValue, RuntimeOptionValue,
SubtitleMiningContext, SubtitleMiningContext,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot, SubtitleSidebarSnapshot,
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
@@ -79,6 +80,7 @@ export interface IpcServiceDeps {
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>; getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
getSubtitleSidebarOpen?: () => boolean; getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>; getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
@@ -369,6 +371,7 @@ export interface IpcDepsRuntimeOptions {
getCurrentSubtitleRaw: () => string; getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>; getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
getSubtitleSidebarOpen?: () => boolean; getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>; getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
@@ -455,6 +458,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss, getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot, getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getSubtitleSidebarPlaybackState: options.getSubtitleSidebarPlaybackState,
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false), getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
getPlaybackPaused: options.getPlaybackPaused, getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition, getSubtitlePosition: options.getSubtitlePosition,
@@ -742,6 +746,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.getSubtitleSidebarSnapshot(); return await deps.getSubtitleSidebarSnapshot();
}); });
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState, () => {
if (!deps.getSubtitleSidebarPlaybackState) {
throw new Error('Subtitle sidebar playback state is unavailable.');
}
return deps.getSubtitleSidebarPlaybackState();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => { ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
return deps.getSubtitleSidebarOpen?.() ?? false; return deps.getSubtitleSidebarOpen?.() ?? false;
}); });
+44 -76
View File
@@ -92,8 +92,10 @@ import type {
SessionActionDispatchRequest, SessionActionDispatchRequest,
SecondarySubMode, SecondarySubMode,
SubtitleData, SubtitleData,
SubtitleCue,
SubtitleMiningContext, SubtitleMiningContext,
SubtitlePosition, SubtitlePosition,
SubtitleSidebarPlaybackState,
WindowGeometry, WindowGeometry,
} from './types'; } from './types';
import { OPEN_ANKI_CARD_ACTION_ID } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types';
@@ -2047,6 +2049,33 @@ const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
logDebug: (message) => logger.debug(message), logDebug: (message) => logger.debug(message),
}); });
let subtitleSidebarRequestedOpen = false; let subtitleSidebarRequestedOpen = false;
let subtitleSidebarCuesRef: SubtitleCue[] | null = null;
let subtitleSidebarCuesRevision = 0;
/**
* Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no
* mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a
* re-parse of the same source still tells the renderer to fetch the full snapshot.
*/
function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1;
}
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
currentSubtitle: {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
},
};
}
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
let explicitSeekIntentExpiresAtMs = 0; let explicitSeekIntentExpiresAtMs = 0;
@@ -5850,26 +5879,19 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen, getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen,
getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(),
// Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed.
// The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence.
getSubtitleSidebarSnapshot: async () => { getSubtitleSidebarSnapshot: async () => {
const currentSubtitle = {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
const currentTimeSec = appState.mpvClient?.currentTimePos ?? null;
const config = configService.getConfig().subtitleSidebar; const config = configService.getConfig().subtitleSidebar;
const cachedSnapshot = () => ({
...getSubtitleSidebarPlaybackState(),
cues: appState.activeParsedSubtitleCues,
config,
});
const client = appState.mpvClient; const client = appState.mpvClient;
if (!client?.connected) { if (!client?.connected) {
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
} }
try { try {
@@ -5883,16 +5905,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
]); ]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) { if (!videoPath) {
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
} }
if ( if (
shouldUseCachedYoutubeParsedCues({ shouldUseCachedYoutubeParsedCues({
@@ -5901,16 +5914,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
cachedCueCount: appState.activeParsedSubtitleCues.length, cachedCueCount: appState.activeParsedSubtitleCues.length,
}) })
) { ) {
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
} }
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
@@ -5921,30 +5925,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
videoPath, videoPath,
}); });
if (!resolvedSource) { if (!resolvedSource) {
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
} }
try { try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) { if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
} }
const content = await loadSubtitleSourceText(resolvedSource.path); const content = await loadSubtitleSourceText(resolvedSource.path);
@@ -5952,30 +5938,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.activeParsedSubtitleCues = cues; appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey; appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null; appState.activeParsedSubtitleMediaPath = videoPath || null;
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues,
currentTimeSec,
currentSubtitle,
config,
};
} finally { } finally {
await resolvedSource.cleanup?.(); await resolvedSource.cleanup?.();
} }
} catch { } catch {
return { return cachedSnapshot();
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
} }
}, },
getPlaybackPaused: () => getPlaybackPaused: () =>
+2
View File
@@ -74,6 +74,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot']; getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getSubtitleSidebarPlaybackState?: IpcDepsRuntimeOptions['getSubtitleSidebarPlaybackState'];
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen']; getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused']; getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
@@ -276,6 +277,7 @@ export function createMainIpcRuntimeServiceDeps(
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss, getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot, getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getSubtitleSidebarPlaybackState: params.getSubtitleSidebarPlaybackState,
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen, getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
getPlaybackPaused: params.getPlaybackPaused, getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition, getSubtitlePosition: params.getSubtitlePosition,
+2
View File
@@ -256,6 +256,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen), ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
getSubtitleSidebarSnapshot: () => getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot), ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getSubtitleSidebarPlaybackState: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState),
copySubtitleSidebarSelection: async (text: unknown) => { copySubtitleSidebarSelection: async (text: unknown) => {
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.'); if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
clipboard.writeText(text); clipboard.writeText(text);
@@ -15,6 +15,7 @@ export async function setup() {
const commands: unknown[] = []; const commands: unknown[] = [];
const snapshot: SubtitleSidebarSnapshot = { const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'episode-1:track-1', sourceKey: 'episode-1:track-1',
cuesRevision: 1,
cues: [ cues: [
{ text: '最初の台詞', startTime: 0, endTime: 1 }, { text: '最初の台詞', startTime: 0, endTime: 1 },
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 }, { text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
@@ -39,6 +40,7 @@ export async function setup() {
Object.defineProperty(window, 'electronAPI', { Object.defineProperty(window, 'electronAPI', {
value: { value: {
getSubtitleSidebarSnapshot: async () => snapshot, getSubtitleSidebarSnapshot: async () => snapshot,
getSubtitleSidebarPlaybackState: async () => snapshot,
getSessionBindings: async () => [ getSessionBindings: async () => [
{ {
sourcePath: 'keybindings[0].key', sourcePath: 'keybindings[0].key',
@@ -64,6 +66,7 @@ export async function setup() {
} satisfies Pick< } satisfies Pick<
ElectronAPI, ElectronAPI,
| 'getSubtitleSidebarSnapshot' | 'getSubtitleSidebarSnapshot'
| 'getSubtitleSidebarPlaybackState'
| 'getSessionBindings' | 'getSessionBindings'
| 'getConfiguredShortcuts' | 'getConfiguredShortcuts'
| 'getStatsToggleKey' | 'getStatsToggleKey'
+123 -9
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types'; import type {
ElectronAPI,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
} from '../../types';
import { createRendererState } from '../state.js'; import { createRendererState } from '../state.js';
import { import {
applySidebarCssDeclarations, applySidebarCssDeclarations,
@@ -68,7 +72,12 @@ function createCueRow() {
function createListStub() { function createListStub() {
return { return {
innerHTML: '', get innerHTML() {
return '';
},
set innerHTML(_html: string) {
this.children.length = 0;
},
children: [] as ReturnType<typeof createCueRow>[], children: [] as ReturnType<typeof createCueRow>[],
appendChild(child: ReturnType<typeof createCueRow>) { appendChild(child: ReturnType<typeof createCueRow>) {
child.offsetTop = this.children.length * child.clientHeight; child.offsetTop = this.children.length * child.clientHeight;
@@ -180,17 +189,20 @@ test.afterEach(() => {
/** /**
* Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the * Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the
* modal can be changed with `setSnapshot`. Globals are restored (and polling stopped) after * modal (and the playback state derived from it) can be changed with `setSnapshot`; `poll`
* each test. * runs the next scheduled playback tick. Globals are restored after each test.
*/ */
function createSidebarHarness(options: SidebarHarnessOptions = {}) { function createSidebarHarness(options: SidebarHarnessOptions = {}) {
let snapshot: SubtitleSidebarSnapshot = { let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles', sourceKey: 'test-subtitles',
cuesRevision: 1,
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }], cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 }, currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
currentTimeSec: 'currentTimeSec' in options ? options.currentTimeSec : 1.1, currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config }, config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
}; };
const requests = { snapshot: 0, playback: 0 };
let nextPoll: (() => Promise<void>) | null = null;
const mpvCommands: MpvCommand[] = []; const mpvCommands: MpvCommand[] = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
@@ -208,7 +220,15 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
addEventListener: windowEvents.addEventListener, addEventListener: windowEvents.addEventListener,
removeEventListener: windowEvents.removeEventListener, removeEventListener: windowEvents.removeEventListener,
electronAPI: { electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot, getSubtitleSidebarSnapshot: async () => {
requests.snapshot += 1;
return snapshot;
},
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
requests.playback += 1;
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
},
getPlaybackPaused: async () => false, getPlaybackPaused: async () => false,
sendMpvCommand: (command: MpvCommand) => { sendMpvCommand: (command: MpvCommand) => {
mpvCommands.push(command); mpvCommands.push(command);
@@ -281,11 +301,16 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
onVisibilityChanged: (visible) => { onVisibilityChanged: (visible) => {
visibilityChanges.push(visible); visibilityChanges.push(visible);
}, },
schedule: (callback) => {
nextPoll = callback;
return () => {
if (nextPoll === callback) nextPoll = null;
};
},
...options.modalOptions, ...options.modalOptions,
}); });
harnessCleanups.push(() => { harnessCleanups.push(() => {
// Closing stops the snapshot poll so it cannot fire against the next test's globals.
modal.closeSubtitleSidebarModal(); modal.closeSubtitleSidebarModal();
modal.disposeDomEvents(); modal.disposeDomEvents();
restoreGlobals(); restoreGlobals();
@@ -307,6 +332,13 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
windowEvents, windowEvents,
contentEvents, contentEvents,
modalEvents, modalEvents,
requests,
/** Runs the pending playback poll tick, if one is scheduled. */
async poll() {
const tick = nextPoll;
nextPoll = null;
await tick?.();
},
setSnapshot(patch: SnapshotPatch) { setSnapshot(patch: SnapshotPatch) {
snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } }; snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } };
}, },
@@ -675,7 +707,7 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 }, currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 },
currentTimeSec: 18.1, currentTimeSec: 18.1,
}); });
await h.modal.refreshSubtitleSidebarSnapshot(); await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 9); assert.equal(h.state.subtitleSidebarActiveCueIndex, 9);
assert.equal(h.cueList.scrollTop, 260); assert.equal(h.cueList.scrollTop, 260);
@@ -685,12 +717,94 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 }, currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 },
currentTimeSec: 20.1, currentTimeSec: 20.1,
}); });
await h.modal.refreshSubtitleSidebarSnapshot(); await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 10); assert.equal(h.state.subtitleSidebarActiveCueIndex, 10);
assert.deepEqual(h.cueList.scrollToCalls.at(-1), { top: 300, behavior: 'smooth' }); 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 () => { test('subtitle sidebar closes and resumes a hover pause', async () => {
const h = createSidebarHarness({ config: { pauseVideoOnHover: true } }); const h = createSidebarHarness({ config: { pauseVideoOnHover: true } });
h.modal.wireDomEvents(); h.modal.wireDomEvents();
+46 -19
View File
@@ -19,7 +19,11 @@ import {
const MANUAL_SCROLL_HOLD_MS = 1500; const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18; const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
const SNAPSHOT_POLL_INTERVAL_MS = 80; // While open, the sidebar polls cheap cached playback state for active-cue tracking and only
// fetches the full snapshot (mpv source resolution + every cue + config) when the source or
// parsed cues changed, plus this fallback in case main missed a change.
const PLAYBACK_POLL_INTERVAL_MS = 80;
const FULL_SNAPSHOT_FALLBACK_POLLS = 25;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240; const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45; const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>(); const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
@@ -206,9 +210,18 @@ export function createSubtitleSidebarModal(
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>; modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
onVisibilityChanged?: (visible: boolean) => void; onVisibilityChanged?: (visible: boolean) => void;
shouldRestoreOpenOnStartup?: () => Promise<boolean>; shouldRestoreOpenOnStartup?: () => Promise<boolean>;
/** Schedules a poll tick and returns its cancel function. Defaults to `setTimeout`. */
schedule?: (callback: () => Promise<void>, delayMs: number) => () => void;
}, },
) { ) {
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null; const schedule =
options.schedule ??
((callback: () => Promise<void>, delayMs: number) => {
const timer = setTimeout(callback, delayMs);
return () => clearTimeout(timer);
});
let cancelPlaybackPoll: (() => void) | null = null;
let pollsSinceFullSnapshot = 0;
let lastAppliedVideoMarginRatio: number | null = null; let lastAppliedVideoMarginRatio: number | null = null;
let subtitleSidebarHoverRequestId = 0; let subtitleSidebarHoverRequestId = 0;
let disposeDomEvents: (() => void) | null = null; let disposeDomEvents: (() => void) | null = null;
@@ -218,6 +231,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarPauseHeldByYomitanPopup = false; let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1; let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null; let subtitleSourceKey: string | null = null;
let subtitleCuesRevision: number | null = null;
function restoreEmbeddedSidebarPassthrough(): void { function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx); syncOverlayMouseIgnoreState(ctx);
@@ -576,6 +590,8 @@ export function createSubtitleSidebarModal(
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> { async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot(); const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
pollsSinceFullSnapshot = 0;
subtitleCuesRevision = snapshot.cuesRevision;
if (snapshot.sourceKey !== subtitleSourceKey) { if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList); clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1; lastSubtitleSidebarLookupCueIndex = -1;
@@ -590,8 +606,8 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarModalOpen = false; ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden'); ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true'); ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling(); stopPlaybackPolling();
updateActiveCue(null, snapshot.currentTimeSec ?? null); updateActiveCue(null, snapshot.currentTimeSec);
setStatus('Subtitle sidebar disabled in config.'); setStatus('Subtitle sidebar disabled in config.');
syncEmbeddedSidebarLayout(); syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
@@ -609,38 +625,49 @@ export function createSubtitleSidebarModal(
renderCueList(); renderCueList();
} }
} }
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null); updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec);
syncEmbeddedSidebarLayout(); syncEmbeddedSidebarLayout();
return snapshot; return snapshot;
} }
function startSnapshotPolling(): void { async function pollPlaybackOnce(): Promise<void> {
stopSnapshotPolling(); pollsSinceFullSnapshot += 1;
const playback = await window.electronAPI.getSubtitleSidebarPlaybackState();
if (
playback.sourceKey !== subtitleSourceKey ||
playback.cuesRevision !== subtitleCuesRevision ||
pollsSinceFullSnapshot >= FULL_SNAPSHOT_FALLBACK_POLLS
) {
await refreshSnapshot();
return;
}
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
}
function startPlaybackPolling(): void {
stopPlaybackPolling();
const pollOnce = async (): Promise<void> => { const pollOnce = async (): Promise<void> => {
try { try {
await refreshSnapshot(); await pollPlaybackOnce();
} catch { } catch {
// Keep polling; a transient IPC failure should not stop updates. // Keep polling; a transient IPC failure should not stop updates.
} }
if (!ctx.state.subtitleSidebarModalOpen) { if (!ctx.state.subtitleSidebarModalOpen) {
snapshotPollInterval = null; cancelPlaybackPoll = null;
return; return;
} }
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS); cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS);
}; };
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS); cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS);
} }
function stopSnapshotPolling(): void { function stopPlaybackPolling(): void {
if (!snapshotPollInterval) { cancelPlaybackPoll?.();
return; cancelPlaybackPoll = null;
}
clearTimeout(snapshotPollInterval);
snapshotPollInterval = null;
} }
async function openSubtitleSidebarModal(): Promise<void> { async function openSubtitleSidebarModal(): Promise<void> {
@@ -664,7 +691,7 @@ export function createSubtitleSidebarModal(
renderCueList(); renderCueList();
syncActiveCueClasses(-1); syncActiveCueClasses(-1);
maybeAutoScrollActiveCue(-1, 'auto', true); maybeAutoScrollActiveCue(-1, 'auto', true);
startSnapshotPolling(); startPlaybackPolling();
syncEmbeddedSidebarLayout(); syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough(); restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar'); window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
@@ -694,7 +721,7 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarModalOpen = false; ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden'); ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true'); ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling(); stopPlaybackPolling();
syncEmbeddedSidebarLayout(); syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive'); ctx.dom.overlay.classList.remove('interactive');
+1
View File
@@ -68,6 +68,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass', getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot', getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getSubtitleSidebarPlaybackState: 'get-subtitle-sidebar-playback-state',
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open', getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
getPlaybackPaused: 'get-playback-paused', getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position', getSubtitlePosition: 'get-subtitle-position',
+2
View File
@@ -55,6 +55,7 @@ import type {
SubtitleData, SubtitleData,
SubtitleMiningContext, SubtitleMiningContext,
SubtitlePosition, SubtitlePosition,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot, SubtitleSidebarSnapshot,
SubtitleRendererStyleConfig, SubtitleRendererStyleConfig,
SubtitleStyleConfig, SubtitleStyleConfig,
@@ -477,6 +478,7 @@ export interface ElectronAPI {
getCurrentSubtitleRaw: () => Promise<string>; getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>; getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>; getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState: () => Promise<SubtitleSidebarPlaybackState>;
copySubtitleSidebarSelection: (text: string) => Promise<void>; copySubtitleSidebarSelection: (text: string) => Promise<void>;
getSubtitleSidebarOpen: () => Promise<boolean>; getSubtitleSidebarOpen: () => Promise<boolean>;
getPlaybackPaused: () => Promise<boolean | null>; getPlaybackPaused: () => Promise<boolean | null>;
+13 -3
View File
@@ -226,15 +226,25 @@ export interface SubtitleData {
endTime?: number | null; endTime?: number | null;
} }
export interface SubtitleSidebarSnapshot { /**
* Per-tick sidebar state, served from main's cached state without mpv round-trips. When
* `sourceKey` or `cuesRevision` differs from the last full snapshot's, the renderer fetches a
* new `SubtitleSidebarSnapshot`.
*/
export interface SubtitleSidebarPlaybackState {
sourceKey: string | null; sourceKey: string | null;
cues: SubtitleCue[]; /** Bumps whenever main swaps in a different parsed cue array. */
currentTimeSec?: number | null; cuesRevision: number;
currentTimeSec: number | null;
currentSubtitle: { currentSubtitle: {
text: string; text: string;
startTime: number | null; startTime: number | null;
endTime: number | null; endTime: number | null;
}; };
}
export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
cues: SubtitleCue[];
config: SubtitleSidebarSnapshotConfig; config: SubtitleSidebarSnapshotConfig;
} }