From 4d27e068c6c6f89777cc3dab4b9d7015abc00e07 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 25 Oct 2026 21:40:02 -0700 Subject: [PATCH] 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. --- changes/mining-respects-sub-delay.md | 4 +- e2e/playback.e2e.test.ts | 43 +++++++++++++++++++ src/anki-integration.ts | 6 +-- src/anki-integration/card-creation.ts | 2 +- src/core/services/index.ts | 2 +- src/core/services/ipc.test.ts | 1 + src/core/services/mining.ts | 16 +------ src/core/services/mpv-protocol.test.ts | 16 +++++++ src/core/services/mpv-protocol.ts | 5 ++- src/core/services/mpv.ts | 1 + src/core/services/subtitle-clock.ts | 32 ++++++++++++++ .../services/subtitle-cue-navigation.test.ts | 26 ++++++++--- src/core/services/subtitle-cue-navigation.ts | 7 ++- src/main.ts | 16 +++++-- .../autoplay-subtitle-priming-runtime.ts | 9 +++- src/main/runtime/mpv-main-event-main-deps.ts | 6 ++- ...itle-sidebar-selection.electron-fixture.ts | 1 + src/renderer/modals/subtitle-sidebar.test.ts | 15 ++++++- src/renderer/modals/subtitle-sidebar.ts | 7 ++- src/types/subtitle.ts | 3 ++ 20 files changed, 177 insertions(+), 41 deletions(-) create mode 100644 src/core/services/subtitle-clock.ts diff --git a/changes/mining-respects-sub-delay.md b/changes/mining-respects-sub-delay.md index 9361e93c..37de7ae8 100644 --- a/changes/mining-respects-sub-delay.md +++ b/changes/mining-respects-sub-delay.md @@ -1,4 +1,4 @@ type: fixed -area: anki +area: subtitles -- Mined card audio, images, and timestamps now follow mpv's manual subtitle retiming (`sub-delay`, z/x), instead of clipping the subtitle file's original timing. +- mpv's manual subtitle retiming (`sub-delay`, z/x and sub-step) now carries through SubMiner: mined card audio, images, and timestamps clip the retimed line, and the subtitle sidebar highlight, click-to-seek, subtitle navigation, overlay line matching, and replay pause point all follow the retimed timing. diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index 29605f42..786b2bc1 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -224,6 +224,49 @@ test( }, ); +test( + 'subtitle sidebar follows and seeks a sub-delay retimed track', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + const delay = 2; + await session.mpv.command('set_property', 'sub-delay', delay); + try { + // At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1. + await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact'); + await overlay.waitFor( + `document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`, + { description: 'overlay to show the delayed line' }, + ); + await session.runAppCommand('--toggle-subtitle-sidebar'); + await overlay.waitFor( + `document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`, + { description: 'sidebar to highlight the delayed line' }, + ); + + await overlay.evaluate( + `document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`, + ); + const target = FIXTURE_CUES[2].start + delay; + await waitUntil( + async () => { + const time = Number(await session.mpv.command('get_property', 'time-pos')); + return time >= target && time < target + 0.5; + }, + { description: `click to seek to cue 2 at ${target}s on the video clock` }, + ); + + await session.runAppCommand('--toggle-subtitle-sidebar'); + await overlay.waitFor( + `document.getElementById('subtitleSidebarModal').classList.contains('hidden')`, + { description: 'sidebar to close' }, + ); + } finally { + await session.mpv.command('set_property', 'sub-delay', 0); + } + }, +); + // 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/anki-integration.ts b/src/anki-integration.ts index ee29fcac..d004ef22 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -44,10 +44,8 @@ import { getPreferredWordValueFromExtractedFields, } from './anki-field-config'; import { createLogger } from './logger'; -import { - captureLiveSubtitleMiningContext, - shiftSubtitleRangeToVideoClock, -} from './core/services/mining'; +import { captureLiveSubtitleMiningContext } from './core/services/mining'; +import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock'; import { createUiFeedbackState, beginUpdateProgress, diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index ddc4065e..954be4f7 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -10,7 +10,7 @@ import { type MediaTimingReviewRequest, type WordCardKind, } from '../types/anki'; -import { shiftSubtitleRangeToVideoClock } from '../core/services/mining'; +import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock'; import { createLogger } from '../logger'; import type { MediaInput } from '../media-input'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 1b28e02b..9b7006c9 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -22,7 +22,6 @@ export { handleMultiCopyDigit, markLastCardAsAudioCard, mineSentenceCard, - shiftSubtitleRangeToVideoClock, triggerFieldGrouping, updateLastCardFromClipboard, } from './mining'; @@ -56,6 +55,7 @@ export { subtitleCueListSeekTime, subtitleCueSeekTime, } from './subtitle-cue-navigation'; +export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock'; export { createFrequencyDictionaryLookup } from './frequency-dictionary'; export { createJlptVocabularyLookup } from './jlpt-vocab'; export { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index f6d8192e..e63bdc9c 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot { cuesRevision: 1, cues: [], currentTimeSec: null, + subDelay: 0, currentSubtitle: { text: '', startTime: null, endTime: null }, config: { enabled: false, diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 423bbe20..a9a178bb 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -1,5 +1,6 @@ import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker'; import type { SubtitleMiningContext } from '../../types/subtitle'; +import { shiftSubtitleRangeToVideoClock } from './subtitle-clock'; interface SubtitleTimingTrackerLike { getRecentBlocks: (count: number) => string[]; @@ -56,21 +57,6 @@ export function handleMultiCopyDigit( } } -/** - * mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps, - * but mpv plays each line `sub-delay` seconds later (z/x retiming). Media is cut from the - * video, so subtitle timings move onto the video clock before they reach mining. - */ -export function shiftSubtitleRangeToVideoClock( - range: T, - subDelay: number, -): T { - if (!Number.isFinite(subDelay) || subDelay === 0) { - return range; - } - return { ...range, startTime: range.startTime + subDelay, endTime: range.endTime + subDelay }; -} - /** * Snapshot the live mpv subtitle line and its timings as a mining context. Media * generation can run tens of seconds after the user mines (slow audio extraction), diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 4e7d5fcc..17d37745 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -74,6 +74,7 @@ function createDeps(overrides: Partial = {}): { setCurrentSubEnd: () => {}, getCurrentSubEnd: () => 0, setCurrentSubDelay: () => {}, + getCurrentSubDelay: () => 0, emitMediaPathChange: (payload) => { state.mediaPath = payload.path; }, @@ -396,6 +397,21 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is }); }); +test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => { + let pauseAtTime: number | null = null; + const { deps } = createDeps({ + getPendingPauseAtSubEnd: () => true, + getCurrentSubDelay: () => 1.5, + setPauseAtTime: (next) => { + pauseAtTime = next; + }, + }); + + await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps); + + assert.equal(pauseAtTime, 43.5); +}); + test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => { const calls: string[] = []; let currentTimePos = 0; diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index e07f24d2..d67c709a 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -1,6 +1,7 @@ import { MpvSubtitleRenderMetrics } from '../../types'; import { sanitizeMediaTitle } from '../../shared/media-identity'; import { widenHalfwidthKana } from '../utils/halfwidth-kana'; +import { toVideoClock } from './subtitle-clock'; export type MpvMessage = { event?: string; @@ -65,6 +66,7 @@ export interface MpvProtocolHandleMessageDeps { setCurrentSubEnd: (value: number) => void; getCurrentSubEnd: () => number; setCurrentSubDelay: (value: number) => void; + getCurrentSubDelay: () => number; emitMediaPathChange: (payload: { path: string }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void; emitTimePosChange: (payload: { time: number }) => void; @@ -264,7 +266,8 @@ export async function dispatchMpvProtocolMessage( const subEnd = (msg.data as number) || 0; deps.setCurrentSubEnd(subEnd); if (deps.getPendingPauseAtSubEnd() && subEnd > 0) { - deps.setPauseAtTime(subEnd); + // `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`. + deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay())); deps.setPendingPauseAtSubEnd(false); deps.sendCommand({ command: ['set_property', 'pause', false] }); } diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index f06967c1..c74d596d 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -464,6 +464,7 @@ export class MpvIpcClient implements MpvClient { setCurrentSubDelay: (value: number) => { this.currentSubDelay = value; }, + getCurrentSubDelay: () => this.currentSubDelay, emitMediaPathChange: (payload) => { this.emit('media-path-change', payload); }, diff --git a/src/core/services/subtitle-clock.ts b/src/core/services/subtitle-clock.ts new file mode 100644 index 00000000..7daf59c6 --- /dev/null +++ b/src/core/services/subtitle-clock.ts @@ -0,0 +1,32 @@ +/** + * mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps + * (the subtitle clock), but mpv plays each line `sub-delay` seconds later (z/x retiming, + * `sub-step`). Playback position, seeks, and media cuts are on the video clock, so any + * value crossing between the two converts here. + */ + +function finiteDelay(subDelay: number): number { + return Number.isFinite(subDelay) ? subDelay : 0; +} + +/** Playback position (`time-pos`) on the subtitle clock, comparable with cue times. */ +export function toSubtitleClock(videoTimeSec: number, subDelay: number): number { + return videoTimeSec - finiteDelay(subDelay); +} + +/** Subtitle-clock seconds on the video clock, for seeks. */ +export function toVideoClock(subtitleTimeSec: number, subDelay: number): number { + return subtitleTimeSec + finiteDelay(subDelay); +} + +/** A subtitle-clock range on the video clock, for mining and media generation. */ +export function shiftSubtitleRangeToVideoClock( + range: T, + subDelay: number, +): T { + const delay = finiteDelay(subDelay); + if (delay === 0) { + return range; + } + return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay }; +} diff --git a/src/core/services/subtitle-cue-navigation.test.ts b/src/core/services/subtitle-cue-navigation.test.ts index 00198fb4..28f859e9 100644 --- a/src/core/services/subtitle-cue-navigation.test.ts +++ b/src/core/services/subtitle-cue-navigation.test.ts @@ -26,7 +26,7 @@ test('next subtitle navigation skips generated ASS events and seeks to the next }, ]; - assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [ + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [ 'seek', 13.08, 'absolute+exact', @@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo { startTime: 13.02, endTime: 16, text: 'Next English' }, ]; - assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [ + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [ 'seek', 13.08, 'absolute+exact', @@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () = { startTime: 16, endTime: 19, text: 'next lyric' }, ]; - assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [ + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [ 'seek', 16.08, 'absolute+exact', @@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior { startTime: 13, endTime: 16, text: 'current line' }, ]; - assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [ + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [ 'seek', 10.08, 'absolute+exact', @@ -78,8 +78,22 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior test('subtitle navigation falls back when no sanitized destination exists', () => { const cues = [{ startTime: 10, endTime: 13, text: 'only line' }]; - assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null); - assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null); + assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null); + assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null); +}); + +test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => { + const cues = [ + { startTime: 10, endTime: 13, text: 'one' }, + { startTime: 13, endTime: 16, text: 'two' }, + ]; + + // At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock). + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [ + 'seek', + 15.08, + 'absolute+exact', + ]); }); test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => { diff --git a/src/core/services/subtitle-cue-navigation.ts b/src/core/services/subtitle-cue-navigation.ts index f0248102..c1cd0a04 100644 --- a/src/core/services/subtitle-cue-navigation.ts +++ b/src/core/services/subtitle-cue-navigation.ts @@ -1,3 +1,4 @@ +import { toSubtitleClock, toVideoClock } from './subtitle-clock'; import type { SubtitleCue } from './subtitle-cue-parser'; const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05; @@ -80,8 +81,10 @@ export function subtitleCueListSeekTime( export function resolveSanitizedSubtitleSeekCommand( command: readonly (string | number)[], cues: readonly SubtitleCue[], - currentTimeSec: number, + videoTimeSec: number, + subDelay: number, ): (string | number)[] | null { + const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay); if ( command.length < 2 || command[0] !== 'sub-seek' || @@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand( if (!destination) { return null; } - return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact']; + return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact']; } diff --git a/src/main.ts b/src/main.ts index 0fb1c674..df743732 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,6 +332,7 @@ import { showMpvOsdRuntime, JellyfinPlaybackReporter, tokenizeSubtitle as tokenizeSubtitleCore, + toSubtitleClock, triggerFieldGrouping as triggerFieldGroupingCore, upsertYomitanDictionarySettings, updateLastCardFromClipboard as updateLastCardFromClipboardCore, @@ -1938,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { const canonical = resolveCanonicalPrimarySubtitle({ liveText: payload.text, - currentTimeSec: Number(appState.mpvClient?.currentTimePos), + currentTimeSec: toSubtitleClock( + Number(appState.mpvClient?.currentTimePos), + appState.mpvClient?.currentSubDelay ?? 0, + ), cues: appState.activeParsedSubtitleCues, }); return { @@ -1956,7 +1960,7 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n const subDelay = appState.mpvClient?.currentSubDelay ?? 0; const resolved = resolvePrimarySubtitle({ liveText: appState.mpvClient?.currentSubText ?? '', - currentTimeSec: Number(appState.mpvClient?.currentTimePos) - subDelay, + currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay), cues: appState.activeParsedSubtitleCues, }); // Same validity bar as the live capture path: an unusable resolved span must fall @@ -2122,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState { subtitleSidebarCuesRef = appState.activeParsedSubtitleCues; subtitleSidebarCuesRevision += 1; } + const subDelay = appState.mpvClient?.currentSubDelay ?? 0; return { sourceKey: JSON.stringify([ appState.activeParsedSubtitleMediaPath, appState.activeParsedSubtitleSource, ]), cuesRevision: subtitleSidebarCuesRevision, - currentTimeSec: appState.mpvClient?.currentTimePos ?? null, + currentTimeSec: + appState.mpvClient?.currentTimePos === undefined + ? null + : toSubtitleClock(appState.mpvClient.currentTimePos, subDelay), + subDelay, currentSubtitle: { text: appState.currentSubText, startTime: appState.mpvClient?.currentSubStart ?? null, @@ -2167,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void { rawCommand, appState.activeParsedSubtitleCues, appState.mpvClient?.currentTimePos ?? Number.NaN, + appState.mpvClient?.currentSubDelay ?? 0, ) ?? rawCommand; if (isExplicitMpvSeekCommand(command)) { explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS; diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.ts index 3ce188a2..b2b8854b 100644 --- a/src/main/runtime/autoplay-subtitle-priming-runtime.ts +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.ts @@ -1,3 +1,4 @@ +import { toSubtitleClock } from '../../core/services/subtitle-clock'; import type { SubtitleCue, SubtitleData } from '../../types'; import { selectAutoplayStartupCue } from './autoplay-subtitle-primer'; import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot'; @@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = { requestProperty: (name: string) => Promise; currentVideoPath?: string; currentTimePos?: number; + currentSubDelay?: number; currentSubText?: string; currentSecondarySubText?: string; setCurrentSecondarySubText?: (text: string) => void; @@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos()); return resolvePrimarySubtitleText({ liveText: text, - currentTimeSec, + currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0), cues: deps.getActiveParsedSubtitleCues(), }); } @@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi const currentTimeSeconds = Number( timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0, ); - const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0; + const resolvedTimeSeconds = toSubtitleClock( + Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0, + client?.currentSubDelay ?? 0, + ); const cue = selectAutoplayStartupCue( cues, resolvedTimeSeconds, diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 17602791..426e0f64 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -1,3 +1,4 @@ +import { toSubtitleClock } from '../../core/services/subtitle-clock'; import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate'; import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; import { @@ -162,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { resolveSubtitleText: (liveText: string) => resolvePrimarySubtitleText({ liveText, - currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos), + currentTimeSec: toSubtitleClock( + Number(deps.appState.mpvClient?.currentTimePos), + deps.appState.mpvClient?.currentSubDelay ?? 0, + ), cues: deps.appState.activeParsedSubtitleCues, }), getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '', diff --git a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts index e920d73c..95e943c2 100644 --- a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts +++ b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts @@ -28,6 +28,7 @@ export async function setup() { ], currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 }, currentTimeSec: 0, + subDelay: 0, config: { ...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar, enabled: true, diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index ec5ecb70..c3de5006 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -199,6 +199,7 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) { 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 }; @@ -226,8 +227,8 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) { }, getSubtitleSidebarPlaybackState: async (): Promise => { requests.playback += 1; - const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot; - return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle }; + const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot; + return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle }; }, getPlaybackPaused: async () => false, sendMpvCommand: (command: MpvCommand) => { @@ -528,6 +529,16 @@ test('subtitle sidebar seeks to the selected cue start using the overlap-aware s 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(); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index df3ad235..47f28c97 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -4,6 +4,7 @@ import type { SubtitleMiningContext, SubtitleSidebarSnapshot, } from '../../types'; +import { toVideoClock } from '../../core/services/subtitle-clock.js'; import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; @@ -231,6 +232,8 @@ export function createSubtitleSidebarModal( let lastSubtitleSidebarLookupCueIndex = -1; let subtitleSourceKey: string | null = null; let subtitleCuesRevision: number | null = null; + // Latest mpv `sub-delay` from main; cue times plus this are where mpv plays the cue. + let subtitleSubDelay = 0; function restoreEmbeddedSidebarPassthrough(): void { syncOverlayMouseIgnoreState(ctx); @@ -416,7 +419,7 @@ export function createSubtitleSidebarModal( function seekToCue(cue: SubtitleCue): void { window.electronAPI.sendMpvCommand([ 'seek', - subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), + toVideoClock(subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), subtitleSubDelay), 'absolute+exact', ]); } @@ -591,6 +594,7 @@ export function createSubtitleSidebarModal( const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot(); pollsSinceFullSnapshot = 0; subtitleCuesRevision = snapshot.cuesRevision; + subtitleSubDelay = snapshot.subDelay; if (snapshot.sourceKey !== subtitleSourceKey) { clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList); lastSubtitleSidebarLookupCueIndex = -1; @@ -640,6 +644,7 @@ export function createSubtitleSidebarModal( await refreshSnapshot(); return; } + subtitleSubDelay = playback.subDelay; updateActiveCue(playback.currentSubtitle, playback.currentTimeSec); } diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 8b35a6e3..8fd30907 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -235,7 +235,10 @@ export interface SubtitleSidebarPlaybackState { sourceKey: string | null; /** Bumps whenever main swaps in a different parsed cue array. */ cuesRevision: number; + /** Playback position on the subtitle clock (`time-pos` minus `sub-delay`), like cue times. */ currentTimeSec: number | null; + /** mpv `sub-delay`: cue times plus this give the video-clock seek target. */ + subDelay: number; currentSubtitle: { text: string; startTime: number | null;