fix(overlay): deduplicate secondary subtitle rendering (#208)

This commit is contained in:
2026-08-18 00:44:02 -07:00
committed by GitHub
parent 7de73e16a1
commit e9778a945a
21 changed files with 749 additions and 71 deletions
+33 -39
View File
@@ -168,9 +168,40 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
]);
});
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
test('mineSentenceCard uses normalized secondary subtitle state instead of raw mpv text', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
let requestedRawSecondaryText = false;
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'Your\nmosaic',
requestProperty: async () => {
requestedRawSecondaryText = true;
return 'Your\nYour\nYour\nYour\nmosaic';
},
},
showMpvOsd: () => {},
});
assert.equal(requestedRawSecondaryText, false);
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'Your\nmosaic' }]);
});
test('mineSentenceCard omits normalized secondary text that matches the primary subtitle', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
const requestedProperties: string[] = [];
await mineSentenceCard({
ankiIntegration: {
@@ -188,43 +219,6 @@ test('mineSentenceCard refreshes secondary subtitle text before creating card',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: '日本語字幕',
requestProperty: async (name: string) => {
requestedProperties.push(name);
return name === 'secondary-sub-text' ? 'English subtitle' : null;
},
},
showMpvOsd: () => {},
});
assert.deepEqual(requestedProperties, ['secondary-sub-text']);
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]);
});
test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'stale cached subtitle',
requestProperty: async (name: string) => {
if (name === 'secondary-sub-text') {
return '';
}
return null;
},
},
showMpvOsd: () => {},
});
+3 -11
View File
@@ -129,18 +129,10 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
return trimmed;
}
async function getCurrentSecondarySubTextForSentenceCard(
function getCurrentSecondarySubTextForSentenceCard(
mpvClient: MpvClientLike,
primaryText: string,
): Promise<string | undefined> {
if (mpvClient.requestProperty) {
try {
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
return normalizeSecondarySubText(latestSecondaryText, primaryText);
} catch {
// Fall back to the cached secondary subtitle below.
}
}
): string | undefined {
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText);
}
@@ -192,7 +184,7 @@ export async function mineSentenceCard(deps: {
return false;
}
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
return await anki.createSentenceCard(
primaryText,
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
+2
View File
@@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'secondary-sub-visibility',
'sub-visibility',
'sid',
'secondary-sid',
'secondary-sub-delay',
'track-list',
];
+33 -1
View File
@@ -63,6 +63,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
emitSubtitleTrackChange: (payload) => state.events.push(payload),
emitSecondarySubtitleTrackChange: (payload) => state.events.push(payload),
emitSecondarySubtitleDelayChange: (payload) => state.events.push(payload),
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => {
@@ -158,12 +160,42 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sub-delay', data: '0.5' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
deps,
);
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
assert.deepEqual(state.events, [
{ sid: 3 },
{ sid: 4 },
{ delay: 0.5 },
{ trackList: [{ type: 'sub', id: 3 }] },
]);
});
test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '4.5' }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4.5' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: 4.5 }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: 4.5 },
deps,
);
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
});
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
+21 -1
View File
@@ -54,6 +54,8 @@ export interface MpvProtocolHandleMessageDeps {
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
@@ -281,7 +283,25 @@ export async function dispatchMpvProtocolMessage(
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
} else if (msg.name === 'secondary-sid') {
const sid =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSecondarySubtitleTrackChange({
sid: sid !== null && Number.isInteger(sid) ? sid : null,
});
} else if (msg.name === 'secondary-sub-delay') {
const delay =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: 0;
deps.emitSecondarySubtitleDelayChange({ delay: Number.isFinite(delay) ? delay : 0 });
} else if (msg.name === 'track-list') {
deps.emitSubtitleTrackListChange({
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
+8
View File
@@ -131,6 +131,8 @@ export interface MpvIpcClientEventMap {
'fullscreen-change': { fullscreen: boolean };
'secondary-subtitle-change': { text: string };
'subtitle-track-change': { sid: number | null };
'secondary-subtitle-track-change': { sid: number | null };
'secondary-subtitle-delay-change': { delay: number };
'subtitle-track-list-change': { trackList: unknown[] | null };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
@@ -438,6 +440,12 @@ export class MpvIpcClient implements MpvClient {
emitSubtitleTrackChange: (payload) => {
this.emit('subtitle-track-change', payload);
},
emitSecondarySubtitleTrackChange: (payload) => {
this.emit('secondary-subtitle-track-change', payload);
},
emitSecondarySubtitleDelayChange: (payload) => {
this.emit('secondary-subtitle-delay-change', payload);
},
emitSubtitleTrackListChange: (payload) => {
this.emit('subtitle-track-list-change', payload);
},