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
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: mining
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one.
+1 -1
View File
@@ -35,7 +35,7 @@ These work when the overlay window has focus.
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` | | `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | | `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1``9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin. The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1``9` to select the total number of subtitle lines to combine, ending at the current line and moving backward through the subtitle timeline. The current line counts toward the selected total. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
## Overlay Controls ## Overlay Controls
+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'); 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 () => { test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = []; const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = []; 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 () => { test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = []; const created: Array<{ sentence: string; secondarySub?: string }> = [];
const tracker = new SubtitleTimingTracker(); 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.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3); assert.equal(immersion.length, 3);
// A jump of exactly the seek threshold counts as a seek, matching the time-pos // Jumping back to a brief previous line moves time-pos by less than the general
// handler's own `>=` boundary. // seek threshold. It is still a backward seek, so the revisited line records
handlers.onTimePosUpdate?.(4.5); // again; otherwise multi-line copy would keep treating the later line as current.
handlers.onTimePosUpdate?.(2); handlers.onTimePosUpdate?.(3.9);
handlers.onTimePosUpdate?.(2.9);
handlers.recordSubtitleTiming('今', 0.8, 1.5); handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); 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.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12); handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
assert.equal(immersion.length, 3); assert.equal(immersion.length, 3);
+8 -5
View File
@@ -1,6 +1,5 @@
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 { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
import { import {
resolveCanonicalPrimarySubtitle, resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText, resolvePrimarySubtitleText,
@@ -115,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
// after the change is dropped instead of landing in the next session. // after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0; let subtitleSessionEpoch = 0;
let lastTimePosForTimingReset: number | null = null; 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 => const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`; `${cue.startTime}|${cue.endTime}|${cue.text}`;
const resetSubtitleDeduplication = (): void => { const resetSubtitleDeduplication = (): void => {
@@ -344,13 +345,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
consumeExplicitSeek: deps.consumeExplicitSeek, consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time: number) => { onTimePosUpdate: (time: number) => {
// Timing history is a viewing log: after a real backward seek, a rewatched // Timing history is a viewing log: after any backward seek, a rewatched canonical
// canonical line should enter it again. Immersion stats keep their // line should enter it again so multi-line copy treats it as the current line.
// once-per-media deduplication and are not reset here. // 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 ( if (
Number.isFinite(time) && Number.isFinite(time) &&
lastTimePosForTimingReset !== null && lastTimePosForTimingReset !== null &&
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS
) { ) {
recordedTimingCanonicalKeys.clear(); recordedTimingCanonicalKeys.clear();
} }
+50 -16
View File
@@ -67,8 +67,13 @@ export class SubtitleTimingTracker {
// Check for duplicate of most recent entry (deduplicate adjacent repeats) // Check for duplicate of most recent entry (deduplicate adjacent repeats)
const lastEntry = this.history[this.history.length - 1]; const lastEntry = this.history[this.history.length - 1];
if (lastEntry && lastEntry.timingKey === timingKey) { if (
// Update timing to most recent occurrence lastEntry &&
lastEntry.timingKey === timingKey &&
lastEntry.startTime === startTime &&
lastEntry.endTime === endTime
) {
// Refresh metadata for repeated notifications of the same subtitle event.
lastEntry.startTime = startTime; lastEntry.startTime = startTime;
lastEntry.endTime = endTime; lastEntry.endTime = endTime;
lastEntry.secondaryText = displaySecondaryText; lastEntry.secondaryText = displaySecondaryText;
@@ -107,28 +112,20 @@ export class SubtitleTimingTracker {
} }
/** /**
* Get recent subtitle blocks in chronological order. * Get recent subtitle blocks in timeline order.
* Returns the last `count` subtitle events (oldest → newest). * Returns up to `count` known subtitle events ending at the current event.
* Blocks preserve internal line breaks and are joined with blank lines. * Blocks preserve internal line breaks and are joined with blank lines.
*/ */
getRecentBlocks(count: number): string[] { getRecentBlocks(count: number): string[] {
if (count <= 0) return []; return this.getRecentTimelineEntries(count).map((entry) => entry.displayText);
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => entry.displayText);
} }
/** /**
* Get recent subtitle blocks with their original event timings. * Get recent subtitle blocks with their original event timings in timeline order.
* Returns the last `count` subtitle events (oldest → newest). * Returns up to `count` known subtitle events ending at the current event.
*/ */
getRecentEntries(count: number): SubtitleTimingBlock[] { getRecentEntries(count: number): SubtitleTimingBlock[] {
if (count <= 0) return []; return this.getRecentTimelineEntries(count).map((entry) => ({
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => ({
displayText: entry.displayText, displayText: entry.displayText,
startTime: entry.startTime, startTime: entry.startTime,
endTime: entry.endTime, endTime: entry.endTime,
@@ -144,6 +141,43 @@ export class SubtitleTimingTracker {
return lastEntry ? lastEntry.displayText : null; 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 { private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
let bestMatch: TimingEntry | null = null; let bestMatch: TimingEntry | null = null;
let bestScore = 0; let bestScore = 0;