fix(subtitles): navigate by sanitized cues across ASS animation

- Route subtitle navigation through parsed cue boundaries
- Treat explicit short seeks as subtitle priming events
This commit is contained in:
2026-08-20 00:20:51 -07:00
parent c01bcd9d0f
commit 9445aef004
11 changed files with 259 additions and 14 deletions
@@ -358,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () =>
]);
});
test('time-pos handler treats an explicit short jump as a seek', () => {
const updateKinds: string[] = [];
let explicitSeekPending = false;
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: () => {},
reportJellyfinRemoteProgress: () => {},
refreshDiscordPresence: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
consumeExplicitSeek: () => {
const pending = explicitSeekPending;
explicitSeekPending = false;
return pending;
},
onTimePosUpdate: (_time, kind) => updateKinds.push(kind),
});
timeHandler({ time: 10 });
explicitSeekPending = true;
timeHandler({ time: 11.5 });
timeHandler({ time: 11.6 });
assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
+3 -1
View File
@@ -141,14 +141,16 @@ export function createHandleMpvTimePosChangeHandler(deps: {
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
consumeExplicitSeek?: () => boolean;
}) {
let lastObservedTime: number | null = null;
return ({ time }: { time: number }): void => {
const explicitSeek = deps.consumeExplicitSeek?.() ?? false;
const updateKind: TimePosUpdateKind =
lastObservedTime === null
? 'initial'
: isSeekLikeTimeChange(lastObservedTime, time)
: explicitSeek || isSeekLikeTimeChange(lastObservedTime, time)
? 'seek'
: 'playback';
const forceImmediate = updateKind === 'seek';
@@ -79,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
recordPauseState: (paused: boolean) => void;
@@ -172,6 +173,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time, updateKind) => {
deps.onTimePosUpdate?.(time);
if (updateKind === 'playback') return;
@@ -86,6 +86,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
@@ -334,6 +335,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time: number) => {
// Timing history is a viewing log: after a real backward seek, a rewatched
// canonical line should enter it again. Immersion stats keep their