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);
},
+35 -1
View File
@@ -528,6 +528,7 @@ import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from './main/runtime/subtitle-prefetch-runtime';
import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track';
import {
createCreateAnilistSetupWindowHandler,
createCreateConfigSettingsWindowHandler,
@@ -1976,7 +1977,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
getLastObservedTimePos: () => lastObservedTimePos,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
emitSecondarySubtitle: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
secondarySubtitleTrackController.handleLiveText(text);
},
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
@@ -2034,6 +2035,24 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
logDebug: (message) => logger.debug(message),
});
const secondarySubtitleTrackController = createSecondarySubtitleTrackController({
getMpvClient: () => appState.mpvClient,
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos,
resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
setCurrentSecondaryText: (text) => {
if (appState.mpvClient) {
appState.mpvClient.currentSecondarySubText = text;
}
},
broadcastSecondaryText: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
@@ -4416,6 +4435,7 @@ const {
onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -4434,6 +4454,9 @@ const {
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
onSecondarySubtitleChange: (text) => {
secondarySubtitleTrackController.handleLiveText(text);
},
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
emitSubtitlePayload(payload);
@@ -4467,6 +4490,7 @@ const {
appState.activeParsedSubtitleMediaPath,
);
if ((normalizedPath || null) !== previousPath) {
secondarySubtitleTrackController.reset();
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
@@ -4501,6 +4525,7 @@ const {
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
// Retry after a short delay because MPV can populate track-list after path.
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
@@ -4555,6 +4580,7 @@ const {
subtitlePrefetchService.onSeek(time);
}
lastObservedTimePos = time;
secondarySubtitleTrackController.handleTimePos(time);
},
onFullscreenChange: (fullscreen) => {
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
@@ -4582,6 +4608,13 @@ const {
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
},
onSecondarySubtitleTrackChange: () => {
secondarySubtitleTrackController.handleTrackChange();
secondarySubtitleTrackController.scheduleRefresh(0);
},
onSecondarySubtitleDelayChange: (delay) => {
secondarySubtitleTrackController.handleDelayChange(delay);
},
onSubtitleTrackListChange: (trackList) => {
const diagnostics = buildSubtitleTrackDiagnostics(
lastObservedPrimarySubtitleTrackId,
@@ -4595,6 +4628,7 @@ const {
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
}
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
secondarySubtitleTrackController.scheduleRefresh(0);
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
},
@@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => {
onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {},
onSubtitleTrackChange: () => {},
onSecondarySubtitleTrackChange: () => {},
onSecondarySubtitleDelayChange: () => {},
onSubtitleTrackListChange: () => {},
onSubtitleTiming: () => {},
onMediaPathChange: () => {},
@@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => {
'subtitle-ass-change',
'secondary-subtitle-change',
'subtitle-track-change',
'secondary-subtitle-track-change',
'secondary-subtitle-delay-change',
'subtitle-track-list-change',
'subtitle-timing',
'media-path-change',
@@ -4,6 +4,8 @@ type MpvBindingEventName =
| 'subtitle-ass-change'
| 'secondary-subtitle-change'
| 'subtitle-track-change'
| 'secondary-subtitle-track-change'
| 'secondary-subtitle-delay-change'
| 'subtitle-track-list-change'
| 'subtitle-timing'
| 'media-path-change'
@@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: {
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
onSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string | null }) => void;
@@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: {
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange);
mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange);
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
mpvClient.on('media-path-change', deps.onMediaPathChange);
@@ -37,6 +37,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
onSecondarySubtitleDelayChange: (delay) =>
calls.push(`secondary-subtitle-delay-change:${delay}`),
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
@@ -73,6 +76,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
handlers.get('media-path-change')?.({ path: '' });
@@ -86,6 +91,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.equal(calls.includes('broadcast-sub:line'), true);
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
assert.ok(calls.includes('subtitle-track-list-change'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
@@ -55,6 +55,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
broadcastSubtitleAss: (text: string) => void;
broadcastSecondarySubtitle: (text: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleDelayChange?: (delay: number) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
@@ -191,6 +193,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid),
onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay),
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
onSubtitleTiming: handleMpvSubtitleTiming,
onMediaPathChange: handleMpvMediaPathChange,
@@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
@@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.onSecondarySubtitleTrackChange?.(4);
deps.onSecondarySubtitleDelayChange?.(0.5);
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
@@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('timing:y:secondary'));
assert.ok(calls.includes('secondary:sec'));
assert.ok(calls.includes('secondary-track:4'));
assert.ok(calls.includes('secondary-delay:0.5'));
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
+16 -2
View File
@@ -61,11 +61,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSecondarySubtitleChange?: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleDelayChange?: (delay: number) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
@@ -274,6 +277,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.appState.activeParsedSubtitleSource = null;
deps.onSubtitleTrackChange?.(sid);
},
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
: undefined,
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
: undefined,
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
: undefined,
@@ -283,8 +292,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
broadcastSubtitleAss: (text: string) =>
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
broadcastSecondarySubtitle: (text: string) => {
if (deps.onSecondarySubtitleChange) {
deps.onSecondarySubtitleChange(text);
return;
}
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
updateCurrentMediaPath: (path: string) => {
resetSubtitleDeduplication();
deps.updateCurrentMediaPath(path);
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import {
createSecondarySubtitleTrackController,
findActiveSubtitleText,
} from './secondary-subtitle-track';
test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: 'Your' },
{ startTime: 1, endTime: 3, text: 'Your' },
{ startTime: 1, endTime: 3, text: 'mosaic' },
],
2,
),
'Your\nmosaic',
);
});
test('secondary track controller parses the selected ASS file before publishing', async () => {
const broadcasts: string[] = [];
let currentText = '';
const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = [];
const ass = `[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`;
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async (input) => {
resolverInputs.push(input);
return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' };
},
loadSubtitleSourceText: async () => ass,
parseSubtitleCues,
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic');
assert.equal(resolverInputs[0]?.allowSelectedFallback, false);
assert.equal(currentText, 'Your\nmosaic');
assert.deepEqual(broadcasts, ['Your\nmosaic']);
});
test('secondary track controller follows parsed cue timing and subtitle delay', async () => {
const broadcasts: string[] = [];
let time = 2.25;
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0.5;
return null;
},
}),
getCurrentTimePos: () => time,
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 2, endTime: 3, text: 'second' },
],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
controller.handleDelayChange(0);
time = 3.25;
controller.handleTimePos(time);
assert.deepEqual(broadcasts, ['first', 'second', '']);
});
test('secondary track controller clears old parsed text immediately on a track change', async () => {
const broadcasts: string[] = [];
let currentText = '';
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }],
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
controller.handleTrackChange();
controller.handleLiveText('new live text');
assert.equal(currentText, 'new live text');
assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']);
});
test('secondary track controller falls back to live mpv text without a readable source', async () => {
const broadcasts: string[] = [];
let currentText = '';
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 'no';
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => null,
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
controller.handleLiveText('live fallback');
await controller.refresh();
assert.equal(currentText, 'live fallback');
assert.deepEqual(broadcasts, ['live fallback']);
});
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
let resolveCalls = 0;
let parseCalls = 0;
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }];
}
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => {
resolveCalls += 1;
return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' };
},
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => {
parseCalls += 1;
return [{ startTime: 1, endTime: 3, text: 'parsed' }];
},
setCurrentSecondaryText: () => {},
broadcastSecondaryText: () => {},
});
await controller.refresh();
await controller.refresh();
assert.equal(resolveCalls, 1);
assert.equal(parseCalls, 1);
});
test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => {
const broadcasts: string[] = [];
let notifyResolveStarted: (() => void) | undefined;
let releaseResolve: (() => void) | undefined;
let cleanupCalls = 0;
let parseCalls = 0;
const resolveStarted = new Promise<void>((resolve) => {
notifyResolveStarted = resolve;
});
const resolveGate = new Promise<void>((resolve) => {
releaseResolve = resolve;
});
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => {
notifyResolveStarted?.();
await resolveGate;
return {
path: '/subs/secondary.ass',
sourceKey: 'secondary',
cleanup: async () => {
cleanupCalls += 1;
},
};
},
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => {
parseCalls += 1;
return [{ startTime: 1, endTime: 3, text: 'stale' }];
},
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
const refresh = controller.refresh();
await resolveStarted;
controller.reset();
releaseResolve?.();
await refresh;
assert.deepEqual(broadcasts, ['']);
assert.equal(parseCalls, 0);
assert.equal(cleanupCalls, 1);
});
@@ -0,0 +1,233 @@
import type { SubtitleCue } from '../../types/subtitle';
type SecondarySubtitleMpvClient = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type ResolvedSubtitleSource = {
path: string;
sourceKey: string;
cleanup?: () => Promise<void>;
};
type SecondarySubtitleSourceInput = {
currentExternalFilenameRaw: unknown;
currentTrackRaw: unknown;
trackListRaw: unknown;
sidRaw: unknown;
videoPath: string;
allowSelectedFallback?: boolean;
};
const DEFAULT_REFRESH_DELAY_MS = 500;
function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
}
function trackId(value: unknown): number | null {
if (typeof value !== 'number' && typeof value !== 'string') return null;
const number = typeof value === 'number' ? value : Number(value.trim());
return Number.isInteger(number) ? number : null;
}
function buildSelectedTrackIdentity(
trackListRaw: unknown,
sidRaw: unknown,
videoPath: string,
): string | null {
if (!Array.isArray(trackListRaw)) return null;
const sid = trackId(sidRaw);
if (sid === null) return null;
const selectedTrack = trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') return false;
const track = entry as Record<string, unknown>;
return track.type === 'sub' && trackId(track.id) === sid;
}) as Record<string, unknown> | undefined;
if (!selectedTrack) return null;
return JSON.stringify([
videoPath,
sid,
selectedTrack.external === true,
selectedTrack['external-filename'] ?? null,
trackId(selectedTrack['ff-index']),
]);
}
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
if (!Number.isFinite(timeSeconds)) return '';
const seen = new Set<string>();
const activeText: string[] = [];
for (const cue of cues) {
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
const text = cue.text.trim();
if (!text || seen.has(text)) continue;
seen.add(text);
activeText.push(text);
}
return activeText.join('\n');
}
export function createSecondarySubtitleTrackController(deps: {
getMpvClient: () => SecondarySubtitleMpvClient | null;
getCurrentTimePos: () => number;
resolveSubtitleSource: (
input: SecondarySubtitleSourceInput,
) => Promise<ResolvedSubtitleSource | null>;
loadSubtitleSourceText: (source: string) => Promise<string>;
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
setCurrentSecondaryText: (text: string) => void;
broadcastSecondaryText: (text: string) => void;
logDebug?: (message: string) => void;
logWarn?: (message: string, error: unknown) => void;
}) {
let parsedCues: SubtitleCue[] | null = null;
let parsedSourceKey: string | null = null;
let parsedTrackIdentity: string | null = null;
let secondaryDelaySeconds = 0;
let lastLiveText = '';
let lastBroadcastText: string | null = null;
let refreshGeneration = 0;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const publish = (text: string): void => {
deps.setCurrentSecondaryText(text);
if (text === lastBroadcastText) return;
lastBroadcastText = text;
deps.broadcastSecondaryText(text);
};
const resolveAtTime = (timeSeconds: number): string => {
if (!parsedCues) return lastLiveText;
return findActiveSubtitleText(parsedCues, timeSeconds - secondaryDelaySeconds);
};
const useLiveFallback = (): void => {
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
publish(lastLiveText);
};
const refresh = async (): Promise<void> => {
const generation = ++refreshGeneration;
const client = deps.getMpvClient();
if (!client?.connected) {
useLiveFallback();
return;
}
let resolvedSource: ResolvedSubtitleSource | null = null;
try {
const [secondarySid, trackList, videoPathRaw, secondaryDelayRaw] = await Promise.all([
client.requestProperty('secondary-sid').catch(() => null),
client.requestProperty('track-list').catch(() => null),
client.requestProperty('path').catch(() => null),
client.requestProperty('secondary-sub-delay').catch(() => 0),
]);
if (generation !== refreshGeneration) return;
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
if (!videoPath || secondarySid === null || secondarySid === 'no') {
useLiveFallback();
return;
}
secondaryDelaySeconds = finiteNumber(secondaryDelayRaw);
const selectedTrackIdentity = buildSelectedTrackIdentity(trackList, secondarySid, videoPath);
if (selectedTrackIdentity && selectedTrackIdentity === parsedTrackIdentity && parsedCues) {
publish(resolveAtTime(deps.getCurrentTimePos()));
return;
}
resolvedSource = await deps.resolveSubtitleSource({
currentExternalFilenameRaw: null,
currentTrackRaw: null,
trackListRaw: trackList,
sidRaw: secondarySid,
videoPath,
allowSelectedFallback: false,
});
if (generation !== refreshGeneration) return;
if (!resolvedSource) {
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
useLiveFallback();
return;
}
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos()));
return;
}
const content = await deps.loadSubtitleSourceText(resolvedSource.path);
const cues = deps.parseSubtitleCues(content, resolvedSource.path);
if (generation !== refreshGeneration) return;
if (cues.length === 0) {
deps.logDebug?.('[secondary-subtitle-track] selected source contained no parsed cues');
useLiveFallback();
return;
}
parsedCues = cues;
parsedSourceKey = resolvedSource.sourceKey;
parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos()));
} catch (error) {
if (generation !== refreshGeneration) return;
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
useLiveFallback();
} finally {
await resolvedSource?.cleanup?.().catch(() => undefined);
}
};
const scheduleRefresh = (delayMs = DEFAULT_REFRESH_DELAY_MS): void => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
refreshTimer = null;
void refresh();
}, delayMs);
};
const clearSelectedTrack = (): void => {
refreshGeneration += 1;
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = null;
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
secondaryDelaySeconds = 0;
lastLiveText = '';
publish('');
};
return {
refresh,
scheduleRefresh,
handleLiveText(text: string): void {
lastLiveText = text;
publish(resolveAtTime(deps.getCurrentTimePos()));
},
handleTimePos(timeSeconds: number): void {
if (!parsedCues) return;
publish(resolveAtTime(timeSeconds));
},
handleTrackChange(): void {
clearSelectedTrack();
},
handleDelayChange(delaySeconds: number): void {
secondaryDelaySeconds = finiteNumber(delaySeconds);
if (parsedCues) {
publish(resolveAtTime(deps.getCurrentTimePos()));
}
},
reset: clearSelectedTrack,
};
}
@@ -248,3 +248,31 @@ test('subtitle source resolver logs debug when no active subtitle track is selec
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
});
test('subtitle source resolver does not fall back to the primary selected track for secondary', async () => {
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg',
extractInternalSubtitleTrack: async () => {
throw new Error('should not extract the primary track');
},
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: null,
trackListRaw: [
{
type: 'sub',
id: 1,
selected: true,
external: true,
'external-filename': '/subs/primary.ass',
},
],
sidRaw: null,
videoPath: '/media/video.mkv',
allowSelectedFallback: false,
});
assert.equal(resolved, null);
});
+12 -1
View File
@@ -41,6 +41,7 @@ function getActiveSubtitleTrack(
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
allowSelectedFallback: boolean,
): MpvSubtitleTrackLike | null {
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
const track = currentTrackRaw as MpvSubtitleTrackLike;
@@ -68,6 +69,10 @@ function getActiveSubtitleTrack(
return bySid;
}
if (!allowSelectedFallback) {
return null;
}
return (
(trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
@@ -94,6 +99,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
trackListRaw: unknown;
sidRaw: unknown;
videoPath: string;
allowSelectedFallback?: boolean;
}): Promise<ActiveSubtitleSidebarSource | null> => {
const currentExternalFilename =
typeof input.currentExternalFilenameRaw === 'string'
@@ -103,7 +109,12 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
}
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
const track = getActiveSubtitleTrack(
input.currentTrackRaw,
input.trackListRaw,
input.sidRaw,
input.allowSelectedFallback !== false,
);
if (!track) {
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
return null;
+16 -3
View File
@@ -1424,10 +1424,11 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
);
});
test('prepareSecondarySubtitleLines drops layered duplicate lines in short stacks', () => {
// A word-level animation stacks one event per layer copy; the stack is too short for
// the karaoke heuristic but the duplicates are still never distinct content.
test('prepareSecondarySubtitleLines preserves short stacks without layer metadata', () => {
assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [
'Your',
'Your',
'Your',
'Your',
'mosaic',
]);
@@ -1447,6 +1448,18 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
});
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => {
const dialogue = ['Wait', 'Wait', 'Wait'];
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
});
test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => {
const dialogue = ['Wait', 'Go!', 'No!', 'Run!'];
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
});
test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => {
const dialogue = ' I never expected this. \\N\\N But here we are. ';
+3 -9
View File
@@ -677,12 +677,10 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (!isKaraokeLikeLineSet(lines)) {
return lines;
}
// Identical lines in one render are layered copies of the same event (animation
// scripts stack several per glyph), never distinct content -- always drop them, so a
// short stack like "Your ×4 / mosaic" collapses without needing the karaoke
// heuristic. Karaoke-likeness is still judged on the raw stack, where the layered
// repetition is the signal.
const seen = new Set<string>();
const unique: string[] = [];
for (const line of lines) {
@@ -690,10 +688,6 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
seen.add(line);
unique.push(line);
}
if (!isKaraokeLikeLineSet(lines)) {
return unique;
}
return [unique.join(' ')];
}