mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation (#302)
This commit is contained in:
1 parent
d3a94536a8
commit
cc9af79d6e
25 files changed
+395
-54
No files matched your search
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
|
||||
// so the app can resolve their fields; scenarios still assert on `requests`.
|
||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
return [FAKE_ANKI_MODEL];
|
||||
case 'modelFieldNames':
|
||||
return MODEL_FIELDS;
|
||||
case 'addNote':
|
||||
return nextNoteId++;
|
||||
case 'addNote': {
|
||||
const noteId = nextNoteId++;
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
|
||||
return noteId;
|
||||
}
|
||||
case 'updateNoteFields': {
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
|
||||
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
|
||||
return null;
|
||||
}
|
||||
case 'notesInfo': {
|
||||
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
|
||||
const ids = Array.isArray(params.notes) ? params.notes : [];
|
||||
return ids.map((id) => {
|
||||
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
|
||||
if (!fields) return {};
|
||||
return {
|
||||
noteId: id,
|
||||
modelName: FAKE_ANKI_MODEL,
|
||||
tags: [],
|
||||
fields: Object.fromEntries(
|
||||
MODEL_FIELDS.map((name, order) => [
|
||||
name,
|
||||
{ value: String(fields[name] ?? ''), order },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
case 'findNotes':
|
||||
case 'notesInfo':
|
||||
return [];
|
||||
case 'storeMediaFile':
|
||||
return params.filename ?? null;
|
||||
|
||||
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
|
||||
});
|
||||
}
|
||||
|
||||
// Card-kind flags are written before media, so wait for the update carrying audio.
|
||||
function findMediaUpdate(session: E2eSession) {
|
||||
return session.anki.requests.find(
|
||||
(request) =>
|
||||
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
|
||||
);
|
||||
}
|
||||
|
||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||
}
|
||||
@@ -126,10 +134,10 @@ test(
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const update = await waitUntil(
|
||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||
);
|
||||
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||
assert.equal(added.length, 1);
|
||||
@@ -156,6 +164,38 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mining a line retimed with sub-delay clips it where mpv plays it',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// Past the cue's own end, so the line is only on screen because of the delay.
|
||||
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
// MiscInfo stamps the clip start: the cue's start on the video clock.
|
||||
const written = JSON.stringify(session.anki.requests);
|
||||
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
@@ -184,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.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
|
||||
Reference in new issue
Block a user