diff --git a/changes/sidebar-snapshot-poll.md b/changes/sidebar-snapshot-poll.md new file mode 100644 index 00000000..d3272ab8 --- /dev/null +++ b/changes/sidebar-snapshot-poll.md @@ -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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 346ecb0c..343343b9 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -63,7 +63,11 @@ the plugin artifact, persistent config, or session help. The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey` 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 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 diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index fb8b88b4..547b3bff 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -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. test( 'holding Shift over a word opens the Yomitan popup with its dictionary entry', diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index db262953..c6e6e118 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -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, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 3bf12a2e..113ee6dc 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -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; + getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState; getSubtitleSidebarOpen?: () => boolean; getPlaybackPaused: () => boolean | null | Promise; getSubtitlePosition: () => unknown; @@ -369,6 +371,7 @@ export interface IpcDepsRuntimeOptions { getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; getSubtitleSidebarSnapshot?: () => Promise; + getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState; getSubtitleSidebarOpen?: () => boolean; getPlaybackPaused: () => boolean | null | Promise; 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; }); diff --git a/src/main.ts b/src/main.ts index e205e090..33097905 100644 --- a/src/main.ts +++ b/src/main.ts @@ -92,8 +92,10 @@ import type { SessionActionDispatchRequest, SecondarySubMode, SubtitleData, + SubtitleCue, SubtitleMiningContext, SubtitlePosition, + SubtitleSidebarPlaybackState, WindowGeometry, } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types'; @@ -2047,6 +2049,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; @@ -5850,26 +5879,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 { @@ -5883,16 +5905,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({ @@ -5901,16 +5914,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({ @@ -5921,30 +5925,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); @@ -5952,30 +5938,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: () => diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index beadfcf9..da74e427 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -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']; @@ -276,6 +277,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, diff --git a/src/preload.ts b/src/preload.ts index f76ec273..dfcdae7f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -256,6 +256,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); diff --git a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts index e9d141ad..e920d73c 100644 --- a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts +++ b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts @@ -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' diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index a0295ca3..ec5ecb70 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -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[], appendChild(child: ReturnType) { 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) | 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 => { + 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(); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 9eab6efe..a1ae391a 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -19,7 +19,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>(); @@ -206,9 +210,18 @@ export function createSubtitleSidebarModal( modalStateReader: Pick; onVisibilityChanged?: (visible: boolean) => void; shouldRestoreOpenOnStartup?: () => Promise; + /** Schedules a poll tick and returns its cancel function. Defaults to `setTimeout`. */ + schedule?: (callback: () => Promise, delayMs: number) => () => void; }, ) { - let snapshotPollInterval: ReturnType | null = null; + const schedule = + options.schedule ?? + ((callback: () => Promise, 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; @@ -218,6 +231,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); @@ -576,6 +590,8 @@ export function createSubtitleSidebarModal( async function refreshSnapshot(): Promise { const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot(); + pollsSinceFullSnapshot = 0; + subtitleCuesRevision = snapshot.cuesRevision; if (snapshot.sourceKey !== subtitleSourceKey) { clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList); lastSubtitleSidebarLookupCueIndex = -1; @@ -590,8 +606,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()) { @@ -609,38 +625,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 { + 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 => { 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 { @@ -664,7 +691,7 @@ export function createSubtitleSidebarModal( renderCueList(); syncActiveCueClasses(-1); maybeAutoScrollActiveCue(-1, 'auto', true); - startSnapshotPolling(); + startPlaybackPolling(); syncEmbeddedSidebarLayout(); restoreEmbeddedSidebarPassthrough(); window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar'); @@ -694,7 +721,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'); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 00528f8a..9afd5831 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -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', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 41f9598a..6aea5f20 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -55,6 +55,7 @@ import type { SubtitleData, SubtitleMiningContext, SubtitlePosition, + SubtitleSidebarPlaybackState, SubtitleSidebarSnapshot, SubtitleRendererStyleConfig, SubtitleStyleConfig, @@ -477,6 +478,7 @@ export interface ElectronAPI { getCurrentSubtitleRaw: () => Promise; getCurrentSubtitleAss: () => Promise; getSubtitleSidebarSnapshot: () => Promise; + getSubtitleSidebarPlaybackState: () => Promise; copySubtitleSidebarSelection: (text: string) => Promise; getSubtitleSidebarOpen: () => Promise; getPlaybackPaused: () => Promise; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 64b0226d..0785a77a 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -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; }