Compare commits

...
Author SHA1 Message Date
sudacode 8f9287806b fix(mining): copy multi-line subtitles backward from current line
- Select lines in subtitle timeline order after seeking
2026-08-31 23:46:05 -07:00
4 changed files with 77 additions and 15 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.
+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+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
+29
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 }> = [];
+43 -14
View File
@@ -107,28 +107,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 +136,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;