fix(mining): copy multi-line subtitles backward from current line (#231)

This commit is contained in:
2026-09-01 22:17:41 -07:00
committed by GitHub
parent fc5c49e365
commit 87b01155df
6 changed files with 119 additions and 26 deletions
+45
View File
@@ -244,6 +244,35 @@ test('handleMultiCopyDigit copies available history and reports truncation', ()
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
});
test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => {
const copied: string[] = [];
const tracker = new SubtitleTimingTracker();
try {
tracker.recordSubtitle('A', 1, 2);
tracker.recordSubtitle('B', 3, 4);
tracker.recordSubtitle('C', 5, 6);
tracker.recordSubtitle('B', 3, 4);
const deps = {
subtitleTimingTracker: tracker,
writeClipboardText: (text: string) => copied.push(text),
showMpvOsd: () => {},
};
handleMultiCopyDigit(1, deps);
handleMultiCopyDigit(2, deps);
assert.deepEqual(copied, ['B', 'A\n\nB']);
assert.deepEqual(tracker.getRecentEntries(2), [
{ displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined },
{ displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined },
]);
} finally {
tracker.destroy();
}
});
test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = [];
@@ -344,6 +373,22 @@ test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats
}
});
test('subtitle timing history preserves adjacent repeated text with distinct timings', () => {
const tracker = new SubtitleTimingTracker();
try {
tracker.recordSubtitle('same', 1, 2);
tracker.recordSubtitle('same', 3, 4);
assert.deepEqual(tracker.getRecentEntries(2), [
{ displayText: 'same', startTime: 1, endTime: 2, secondaryText: undefined },
{ displayText: 'same', startTime: 3, endTime: 4, secondaryText: undefined },
]);
} finally {
tracker.destroy();
}
});
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
const tracker = new SubtitleTimingTracker();
@@ -503,14 +503,21 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3);
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
// handler's own `>=` boundary.
handlers.onTimePosUpdate?.(4.5);
handlers.onTimePosUpdate?.(2);
// Jumping back to a brief previous line moves time-pos by less than the general
// seek threshold. It is still a backward seek, so the revisited line records
// again; otherwise multi-line copy would keep treating the later line as current.
handlers.onTimePosUpdate?.(3.9);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Tiny time-pos jitter is not a seek and must not re-record the line.
handlers.onTimePosUpdate?.(3.0);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.equal(timing.length, 5);
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
assert.equal(immersion.length, 3);
+8 -5
View File
@@ -1,6 +1,5 @@
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
@@ -115,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
// after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0;
let lastTimePosForTimingReset: number | null = null;
// Small margin so time-pos jitter is not mistaken for a backward seek.
const BACKWARD_SEEK_TIMING_RESET_SECONDS = 0.25;
const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`;
const resetSubtitleDeduplication = (): void => {
@@ -344,13 +345,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
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
// once-per-media deduplication and are not reset here.
// Timing history is a viewing log: after any backward seek, a rewatched canonical
// line should enter it again so multi-line copy treats it as the current line.
// Playback never moves time-pos backward on its own, so even a short jump to a
// brief previous line counts. Immersion stats keep their once-per-media
// deduplication and are not reset here.
if (
Number.isFinite(time) &&
lastTimePosForTimingReset !== null &&
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS
) {
recordedTimingCanonicalKeys.clear();
}
+50 -16
View File
@@ -67,8 +67,13 @@ export class SubtitleTimingTracker {
// Check for duplicate of most recent entry (deduplicate adjacent repeats)
const lastEntry = this.history[this.history.length - 1];
if (lastEntry && lastEntry.timingKey === timingKey) {
// Update timing to most recent occurrence
if (
lastEntry &&
lastEntry.timingKey === timingKey &&
lastEntry.startTime === startTime &&
lastEntry.endTime === endTime
) {
// Refresh metadata for repeated notifications of the same subtitle event.
lastEntry.startTime = startTime;
lastEntry.endTime = endTime;
lastEntry.secondaryText = displaySecondaryText;
@@ -107,28 +112,20 @@ export class SubtitleTimingTracker {
}
/**
* Get recent subtitle blocks in chronological order.
* Returns the last `count` subtitle events (oldest → newest).
* Get recent subtitle blocks in timeline order.
* Returns up to `count` known subtitle events ending at the current event.
* Blocks preserve internal line breaks and are joined with blank lines.
*/
getRecentBlocks(count: number): string[] {
if (count <= 0) return [];
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => entry.displayText);
return this.getRecentTimelineEntries(count).map((entry) => entry.displayText);
}
/**
* Get recent subtitle blocks with their original event timings.
* Returns the last `count` subtitle events (oldest → newest).
* Get recent subtitle blocks with their original event timings in timeline order.
* Returns up to `count` known subtitle events ending at the current event.
*/
getRecentEntries(count: number): SubtitleTimingBlock[] {
if (count <= 0) return [];
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => ({
return this.getRecentTimelineEntries(count).map((entry) => ({
displayText: entry.displayText,
startTime: entry.startTime,
endTime: entry.endTime,
@@ -144,6 +141,43 @@ export class SubtitleTimingTracker {
return lastEntry ? lastEntry.displayText : null;
}
private getRecentTimelineEntries(count: number): HistoryEntry[] {
if (count <= 0) return [];
const currentEntry = this.history[this.history.length - 1];
if (!currentEntry) return [];
const timelineEntries: HistoryEntry[] = [];
for (const entry of this.history) {
const existingIndex = timelineEntries.findIndex((candidate) =>
this.isSameSubtitleEvent(candidate, entry),
);
if (existingIndex === -1) {
timelineEntries.push(entry);
} else {
timelineEntries[existingIndex] = entry;
}
}
timelineEntries.sort(
(left, right) => left.startTime - right.startTime || left.endTime - right.endTime,
);
const currentIndex = timelineEntries.findIndex((entry) =>
this.isSameSubtitleEvent(entry, currentEntry),
);
if (currentIndex === -1) return [];
return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
}
private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean {
return (
left.timingKey === right.timingKey &&
left.startTime === right.startTime &&
left.endTime === right.endTime
);
}
private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
let bestMatch: TimingEntry | null = null;
let bestScore = 0;