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

This commit is contained in:
sudacode committed 2026-10-11 01:02:02 -07:00
1 parent 6c295586cb
commit 9c351a8603
14 files changed
+286 -108

No files matched your search

+2
View File
@@ -86,7 +86,9 @@ function createControllerConfigFixture() {
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
sourceKey: 'test-subtitles',
cuesRevision: 1,
cues: [],
currentTimeSec: null,
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
+11
View File
@@ -12,6 +12,7 @@ import type {
RuntimeOptionId,
RuntimeOptionValue,
SubtitleMiningContext,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
SubtitlePosition,
SubsyncManualRunRequest,
@@ -79,6 +80,7 @@ export interface IpcServiceDeps {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
@@ -369,6 +371,7 @@ export interface IpcDepsRuntimeOptions {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
@@ -455,6 +458,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getSubtitleSidebarPlaybackState: options.getSubtitleSidebarPlaybackState,
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition,
@@ -742,6 +746,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
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, () => {
return deps.getSubtitleSidebarOpen?.() ?? false;
});
+44 -76
View File
@@ -101,8 +101,10 @@ import type {
SessionActionDispatchRequest,
SecondarySubMode,
SubtitleData,
SubtitleCue,
SubtitleMiningContext,
SubtitlePosition,
SubtitleSidebarPlaybackState,
WindowGeometry,
} from './types';
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
@@ -2105,6 +2107,33 @@ const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
logDebug: (message) => logger.debug(message),
});
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 EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
let explicitSeekIntentExpiresAtMs = 0;
@@ -6031,26 +6060,19 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
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 () => {
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 cachedSnapshot = () => ({
...getSubtitleSidebarPlaybackState(),
cues: appState.activeParsedSubtitleCues,
config,
});
const client = appState.mpvClient;
if (!client?.connected) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
try {
@@ -6064,16 +6086,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
if (
shouldUseCachedYoutubeParsedCues({
@@ -6082,16 +6095,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
cachedCueCount: appState.activeParsedSubtitleCues.length,
})
) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
@@ -6102,30 +6106,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
videoPath,
});
if (!resolvedSource) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
const content = await loadSubtitleSourceText(resolvedSource.path);
@@ -6133,30 +6119,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
} finally {
await resolvedSource.cleanup?.();
}
} catch {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
return cachedSnapshot();
}
},
getPlaybackPaused: () =>
+2
View File
@@ -74,6 +74,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getSubtitleSidebarPlaybackState?: IpcDepsRuntimeOptions['getSubtitleSidebarPlaybackState'];
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
@@ -277,6 +278,7 @@ export function createMainIpcRuntimeServiceDeps(
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getSubtitleSidebarPlaybackState: params.getSubtitleSidebarPlaybackState,
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition,
+2
View File
@@ -294,6 +294,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getSubtitleSidebarPlaybackState: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState),
copySubtitleSidebarSelection: async (text: unknown) => {
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
clipboard.writeText(text);
@@ -15,6 +15,7 @@ export async function setup() {
const commands: unknown[] = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'episode-1:track-1',
cuesRevision: 1,
cues: [
{ text: '最初の台詞', startTime: 0, endTime: 1 },
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
@@ -39,6 +40,7 @@ export async function setup() {
Object.defineProperty(window, 'electronAPI', {
value: {
getSubtitleSidebarSnapshot: async () => snapshot,
getSubtitleSidebarPlaybackState: async () => snapshot,
getSessionBindings: async () => [
{
sourcePath: 'keybindings[0].key',
@@ -64,6 +66,7 @@ export async function setup() {
} satisfies Pick<
ElectronAPI,
| 'getSubtitleSidebarSnapshot'
| 'getSubtitleSidebarPlaybackState'
| 'getSessionBindings'
| 'getConfiguredShortcuts'
| 'getStatsToggleKey'
+123 -9
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import type {
ElectronAPI,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
} from '../../types';
import { createRendererState } from '../state.js';
import {
applySidebarCssDeclarations,
@@ -68,7 +72,12 @@ function createCueRow() {
function createListStub() {
return {
innerHTML: '',
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;
@@ -180,17 +189,20 @@ test.afterEach(() => {
/**
* 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
* each test.
* 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 : 1.1,
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
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]> = [];
@@ -208,7 +220,15 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
addEventListener: windowEvents.addEventListener,
removeEventListener: windowEvents.removeEventListener,
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,
sendMpvCommand: (command: MpvCommand) => {
mpvCommands.push(command);
@@ -281,11 +301,16 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
onVisibilityChanged: (visible) => {
visibilityChanges.push(visible);
},
schedule: (callback) => {
nextPoll = callback;
return () => {
if (nextPoll === callback) nextPoll = null;
};
},
...options.modalOptions,
});
harnessCleanups.push(() => {
// Closing stops the snapshot poll so it cannot fire against the next test's globals.
modal.closeSubtitleSidebarModal();
modal.disposeDomEvents();
restoreGlobals();
@@ -307,6 +332,13 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
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 } };
},
@@ -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 },
currentTimeSec: 18.1,
});
await h.modal.refreshSubtitleSidebarSnapshot();
await h.poll();
assert.equal(h.state.subtitleSidebarActiveCueIndex, 9);
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 },
currentTimeSec: 20.1,
});
await h.modal.refreshSubtitleSidebarSnapshot();
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();
+46 -19
View File
@@ -18,7 +18,11 @@ import {
const MANUAL_SCROLL_HOLD_MS = 1500;
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_MAX_RATIO = 0.45;
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
@@ -205,9 +209,18 @@ export function createSubtitleSidebarModal(
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
onVisibilityChanged?: (visible: boolean) => void;
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 subtitleSidebarHoverRequestId = 0;
let disposeDomEvents: (() => void) | null = null;
@@ -217,6 +230,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null;
let subtitleCuesRevision: number | null = null;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -575,6 +589,8 @@ export function createSubtitleSidebarModal(
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
pollsSinceFullSnapshot = 0;
subtitleCuesRevision = snapshot.cuesRevision;
if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1;
@@ -589,8 +605,8 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
updateActiveCue(null, snapshot.currentTimeSec ?? null);
stopPlaybackPolling();
updateActiveCue(null, snapshot.currentTimeSec);
setStatus('Subtitle sidebar disabled in config.');
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
@@ -608,38 +624,49 @@ export function createSubtitleSidebarModal(
renderCueList();
}
}
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec);
syncEmbeddedSidebarLayout();
return snapshot;
}
function startSnapshotPolling(): void {
stopSnapshotPolling();
async function pollPlaybackOnce(): Promise<void> {
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> => {
try {
await refreshSnapshot();
await pollPlaybackOnce();
} catch {
// Keep polling; a transient IPC failure should not stop updates.
}
if (!ctx.state.subtitleSidebarModalOpen) {
snapshotPollInterval = null;
cancelPlaybackPoll = null;
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 {
if (!snapshotPollInterval) {
return;
}
clearTimeout(snapshotPollInterval);
snapshotPollInterval = null;
function stopPlaybackPolling(): void {
cancelPlaybackPoll?.();
cancelPlaybackPoll = null;
}
async function openSubtitleSidebarModal(): Promise<void> {
@@ -663,7 +690,7 @@ export function createSubtitleSidebarModal(
renderCueList();
syncActiveCueClasses(-1);
maybeAutoScrollActiveCue(-1, 'auto', true);
startSnapshotPolling();
startPlaybackPolling();
syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
@@ -693,7 +720,7 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
stopPlaybackPolling();
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
+1
View File
@@ -68,6 +68,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getSubtitleSidebarPlaybackState: 'get-subtitle-sidebar-playback-state',
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position',
+2
View File
@@ -55,6 +55,7 @@ import type {
SubtitleData,
SubtitleMiningContext,
SubtitlePosition,
SubtitleSidebarPlaybackState,
SubtitleSidebarSnapshot,
SubtitleRendererStyleConfig,
SubtitleStyleConfig,
@@ -477,6 +478,7 @@ export interface ElectronAPI {
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarPlaybackState: () => Promise<SubtitleSidebarPlaybackState>;
copySubtitleSidebarSelection: (text: string) => Promise<void>;
getSubtitleSidebarOpen: () => Promise<boolean>;
getPlaybackPaused: () => Promise<boolean | null>;
+13 -3
View File
@@ -226,15 +226,25 @@ export interface SubtitleData {
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;
cues: SubtitleCue[];
currentTimeSec?: number | null;
/** Bumps whenever main swaps in a different parsed cue array. */
cuesRevision: number;
currentTimeSec: number | null;
currentSubtitle: {
text: string;
startTime: number | null;
endTime: number | null;
};
}
export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
cues: SubtitleCue[];
config: SubtitleSidebarSnapshotConfig;
}