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.
This commit is contained in:
sudacode committed 2026-10-25 21:40:02 -07:00
1 parent 929e78b28c
commit 4d27e068c6
20 files changed
+177 -41

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
type: fixed 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.
+43
View File
@@ -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. // 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 -4
View File
@@ -44,10 +44,8 @@ import {
getPreferredWordValueFromExtractedFields, getPreferredWordValueFromExtractedFields,
} from './anki-field-config'; } from './anki-field-config';
import { createLogger } from './logger'; import { createLogger } from './logger';
import { import { captureLiveSubtitleMiningContext } from './core/services/mining';
captureLiveSubtitleMiningContext, import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
shiftSubtitleRangeToVideoClock,
} from './core/services/mining';
import { import {
createUiFeedbackState, createUiFeedbackState,
beginUpdateProgress, beginUpdateProgress,
+1 -1
View File
@@ -10,7 +10,7 @@ import {
type MediaTimingReviewRequest, type MediaTimingReviewRequest,
type WordCardKind, type WordCardKind,
} from '../types/anki'; } from '../types/anki';
import { shiftSubtitleRangeToVideoClock } from '../core/services/mining'; import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { MediaInput } from '../media-input'; import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
+1 -1
View File
@@ -22,7 +22,6 @@ export {
handleMultiCopyDigit, handleMultiCopyDigit,
markLastCardAsAudioCard, markLastCardAsAudioCard,
mineSentenceCard, mineSentenceCard,
shiftSubtitleRangeToVideoClock,
triggerFieldGrouping, triggerFieldGrouping,
updateLastCardFromClipboard, updateLastCardFromClipboard,
} from './mining'; } from './mining';
@@ -56,6 +55,7 @@ export {
subtitleCueListSeekTime, subtitleCueListSeekTime,
subtitleCueSeekTime, subtitleCueSeekTime,
} from './subtitle-cue-navigation'; } from './subtitle-cue-navigation';
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
export { createFrequencyDictionaryLookup } from './frequency-dictionary'; export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab'; export { createJlptVocabularyLookup } from './jlpt-vocab';
export { export {
+1
View File
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
cuesRevision: 1, cuesRevision: 1,
cues: [], cues: [],
currentTimeSec: null, currentTimeSec: null,
subDelay: 0,
currentSubtitle: { text: '', startTime: null, endTime: null }, currentSubtitle: { text: '', startTime: null, endTime: null },
config: { config: {
enabled: false, enabled: false,
+1 -15
View File
@@ -1,5 +1,6 @@
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker'; import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
import type { SubtitleMiningContext } from '../../types/subtitle'; import type { SubtitleMiningContext } from '../../types/subtitle';
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
interface SubtitleTimingTrackerLike { interface SubtitleTimingTrackerLike {
getRecentBlocks: (count: number) => string[]; 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<T extends { startTime: number; endTime: number }>(
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 * 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), * generation can run tens of seconds after the user mines (slow audio extraction),
+16
View File
@@ -74,6 +74,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
setCurrentSubEnd: () => {}, setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0, getCurrentSubEnd: () => 0,
setCurrentSubDelay: () => {}, setCurrentSubDelay: () => {},
getCurrentSubDelay: () => 0,
emitMediaPathChange: (payload) => { emitMediaPathChange: (payload) => {
state.mediaPath = payload.path; 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 () => { test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
const calls: string[] = []; const calls: string[] = [];
let currentTimePos = 0; let currentTimePos = 0;
+4 -1
View File
@@ -1,6 +1,7 @@
import { MpvSubtitleRenderMetrics } from '../../types'; import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity'; import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana'; import { widenHalfwidthKana } from '../utils/halfwidth-kana';
import { toVideoClock } from './subtitle-clock';
export type MpvMessage = { export type MpvMessage = {
event?: string; event?: string;
@@ -65,6 +66,7 @@ export interface MpvProtocolHandleMessageDeps {
setCurrentSubEnd: (value: number) => void; setCurrentSubEnd: (value: number) => void;
getCurrentSubEnd: () => number; getCurrentSubEnd: () => number;
setCurrentSubDelay: (value: number) => void; setCurrentSubDelay: (value: number) => void;
getCurrentSubDelay: () => number;
emitMediaPathChange: (payload: { path: string }) => void; emitMediaPathChange: (payload: { path: string }) => void;
emitMediaTitleChange: (payload: { title: string | null }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void; emitTimePosChange: (payload: { time: number }) => void;
@@ -264,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
const subEnd = (msg.data as number) || 0; const subEnd = (msg.data as number) || 0;
deps.setCurrentSubEnd(subEnd); deps.setCurrentSubEnd(subEnd);
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) { 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.setPendingPauseAtSubEnd(false);
deps.sendCommand({ command: ['set_property', 'pause', false] }); deps.sendCommand({ command: ['set_property', 'pause', false] });
} }
+1
View File
@@ -464,6 +464,7 @@ export class MpvIpcClient implements MpvClient {
setCurrentSubDelay: (value: number) => { setCurrentSubDelay: (value: number) => {
this.currentSubDelay = value; this.currentSubDelay = value;
}, },
getCurrentSubDelay: () => this.currentSubDelay,
emitMediaPathChange: (payload) => { emitMediaPathChange: (payload) => {
this.emit('media-path-change', payload); this.emit('media-path-change', payload);
}, },
+32
View File
@@ -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<T extends { startTime: number; endTime: number }>(
range: T,
subDelay: number,
): T {
const delay = finiteDelay(subDelay);
if (delay === 0) {
return range;
}
return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay };
}
@@ -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', 'seek',
13.08, 13.08,
'absolute+exact', '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' }, { 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', 'seek',
13.08, 13.08,
'absolute+exact', 'absolute+exact',
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
{ startTime: 16, endTime: 19, text: 'next 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', 'seek',
16.08, 16.08,
'absolute+exact', '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' }, { 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', 'seek',
10.08, 10.08,
'absolute+exact', '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', () => { test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }]; const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null); assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), 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', () => { test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
+5 -2
View File
@@ -1,3 +1,4 @@
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
import type { SubtitleCue } from './subtitle-cue-parser'; import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05; const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
export function resolveSanitizedSubtitleSeekCommand( export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[], command: readonly (string | number)[],
cues: readonly SubtitleCue[], cues: readonly SubtitleCue[],
currentTimeSec: number, videoTimeSec: number,
subDelay: number,
): (string | number)[] | null { ): (string | number)[] | null {
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
if ( if (
command.length < 2 || command.length < 2 ||
command[0] !== 'sub-seek' || command[0] !== 'sub-seek' ||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
if (!destination) { if (!destination) {
return null; return null;
} }
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact']; return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
} }
+13 -3
View File
@@ -332,6 +332,7 @@ import {
showMpvOsdRuntime, showMpvOsdRuntime,
JellyfinPlaybackReporter, JellyfinPlaybackReporter,
tokenizeSubtitle as tokenizeSubtitleCore, tokenizeSubtitle as tokenizeSubtitleCore,
toSubtitleClock,
triggerFieldGrouping as triggerFieldGroupingCore, triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings, upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore,
@@ -1938,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({ const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text, liveText: payload.text,
currentTimeSec: Number(appState.mpvClient?.currentTimePos), currentTimeSec: toSubtitleClock(
Number(appState.mpvClient?.currentTimePos),
appState.mpvClient?.currentSubDelay ?? 0,
),
cues: appState.activeParsedSubtitleCues, cues: appState.activeParsedSubtitleCues,
}); });
return { return {
@@ -1956,7 +1960,7 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
const subDelay = appState.mpvClient?.currentSubDelay ?? 0; const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
const resolved = resolvePrimarySubtitle({ const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '', liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos) - subDelay, currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
cues: appState.activeParsedSubtitleCues, cues: appState.activeParsedSubtitleCues,
}); });
// Same validity bar as the live capture path: an unusable resolved span must fall // Same validity bar as the live capture path: an unusable resolved span must fall
@@ -2122,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues; subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1; subtitleSidebarCuesRevision += 1;
} }
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
return { return {
sourceKey: JSON.stringify([ sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath, appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource, appState.activeParsedSubtitleSource,
]), ]),
cuesRevision: subtitleSidebarCuesRevision, cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec: appState.mpvClient?.currentTimePos ?? null, currentTimeSec:
appState.mpvClient?.currentTimePos === undefined
? null
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
subDelay,
currentSubtitle: { currentSubtitle: {
text: appState.currentSubText, text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null, startTime: appState.mpvClient?.currentSubStart ?? null,
@@ -2167,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
rawCommand, rawCommand,
appState.activeParsedSubtitleCues, appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN, appState.mpvClient?.currentTimePos ?? Number.NaN,
appState.mpvClient?.currentSubDelay ?? 0,
) ?? rawCommand; ) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) { if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS; explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
@@ -1,3 +1,4 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import type { SubtitleCue, SubtitleData } from '../../types'; import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer'; import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot'; import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
@@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>; requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string; currentVideoPath?: string;
currentTimePos?: number; currentTimePos?: number;
currentSubDelay?: number;
currentSubText?: string; currentSubText?: string;
currentSecondarySubText?: string; currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void; setCurrentSecondarySubText?: (text: string) => void;
@@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos()); const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({ return resolvePrimarySubtitleText({
liveText: text, liveText: text,
currentTimeSec, currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
cues: deps.getActiveParsedSubtitleCues(), cues: deps.getActiveParsedSubtitleCues(),
}); });
} }
@@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number( const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0, 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( const cue = selectAutoplayStartupCue(
cues, cues,
resolvedTimeSeconds, resolvedTimeSeconds,
+5 -1
View File
@@ -1,3 +1,4 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate'; import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { import {
@@ -162,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resolveSubtitleText: (liveText: string) => resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({ resolvePrimarySubtitleText({
liveText, liveText,
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos), currentTimeSec: toSubtitleClock(
Number(deps.appState.mpvClient?.currentTimePos),
deps.appState.mpvClient?.currentSubDelay ?? 0,
),
cues: deps.appState.activeParsedSubtitleCues, cues: deps.appState.activeParsedSubtitleCues,
}), }),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '', getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
@@ -28,6 +28,7 @@ export async function setup() {
], ],
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 }, currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
currentTimeSec: 0, currentTimeSec: 0,
subDelay: 0,
config: { config: {
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar, ...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
enabled: true, enabled: true,
+13 -2
View File
@@ -199,6 +199,7 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
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 ?? null) : 1.1, currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
subDelay: 0,
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config }, config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
}; };
const requests = { snapshot: 0, playback: 0 }; const requests = { snapshot: 0, playback: 0 };
@@ -226,8 +227,8 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
}, },
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => { getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
requests.playback += 1; requests.playback += 1;
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot; const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot;
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle }; return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle };
}, },
getPlaybackPaused: async () => false, getPlaybackPaused: async () => false,
sendMpvCommand: (command: MpvCommand) => { 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']); 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 () => { test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
const h = createSidebarHarness(); const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal(); await h.modal.openSubtitleSidebarModal();
+6 -1
View File
@@ -4,6 +4,7 @@ import type {
SubtitleMiningContext, SubtitleMiningContext,
SubtitleSidebarSnapshot, SubtitleSidebarSnapshot,
} from '../../types'; } from '../../types';
import { toVideoClock } from '../../core/services/subtitle-clock.js';
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js'; import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
@@ -231,6 +232,8 @@ export function createSubtitleSidebarModal(
let lastSubtitleSidebarLookupCueIndex = -1; let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null; let subtitleSourceKey: string | null = null;
let subtitleCuesRevision: number | 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 { function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx); syncOverlayMouseIgnoreState(ctx);
@@ -416,7 +419,7 @@ export function createSubtitleSidebarModal(
function seekToCue(cue: SubtitleCue): void { function seekToCue(cue: SubtitleCue): void {
window.electronAPI.sendMpvCommand([ window.electronAPI.sendMpvCommand([
'seek', 'seek',
subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), toVideoClock(subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), subtitleSubDelay),
'absolute+exact', 'absolute+exact',
]); ]);
} }
@@ -591,6 +594,7 @@ export function createSubtitleSidebarModal(
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot(); const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
pollsSinceFullSnapshot = 0; pollsSinceFullSnapshot = 0;
subtitleCuesRevision = snapshot.cuesRevision; subtitleCuesRevision = snapshot.cuesRevision;
subtitleSubDelay = snapshot.subDelay;
if (snapshot.sourceKey !== subtitleSourceKey) { if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList); clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1; lastSubtitleSidebarLookupCueIndex = -1;
@@ -640,6 +644,7 @@ export function createSubtitleSidebarModal(
await refreshSnapshot(); await refreshSnapshot();
return; return;
} }
subtitleSubDelay = playback.subDelay;
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec); updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
} }
+3
View File
@@ -235,7 +235,10 @@ export interface SubtitleSidebarPlaybackState {
sourceKey: string | null; sourceKey: string | null;
/** Bumps whenever main swaps in a different parsed cue array. */ /** Bumps whenever main swaps in a different parsed cue array. */
cuesRevision: number; cuesRevision: number;
/** Playback position on the subtitle clock (`time-pos` minus `sub-delay`), like cue times. */
currentTimeSec: number | null; currentTimeSec: number | null;
/** mpv `sub-delay`: cue times plus this give the video-clock seek target. */
subDelay: number;
currentSubtitle: { currentSubtitle: {
text: string; text: string;
startTime: number | null; startTime: number | null;