Compare commits

...
Author SHA1 Message Date
sudacode 8ebf6a45fe test(mpv): cover decimal subtitle track IDs
- Verify decimal numeric IDs are rejected for primary and secondary subtitles
2026-08-18 00:16:20 -07:00
sudacode fc7fde30d5 fix(overlay): prevent secondary subtitle duplication
- Reject decimal subtitle track IDs
- Preserve repeated short dialogue lines
2026-08-17 23:49:08 -07:00
sudacode f335b26fe3 fix(overlay): deduplicate secondary subtitle rendering
- Parse selected secondary tracks through the subtitle deduplication pipeline
- Fall back to live mpv text when source resolution fails
2026-08-17 23:37:09 -07:00
20 changed files with 742 additions and 62 deletions
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
+22 -3
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-08-04
Last verified: 2026-08-17
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -77,6 +77,25 @@ coming and prefetching would otherwise idle for the rest of the cue.
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
or character images, but cue visibility does not wait for that work.
## Secondary Subtitle Flow
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
still appear without waiting for file resolution.
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
source resolver used by primary subtitle prefetching.
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
`secondary-sub-delay`.
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
text when a readable source is available.
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
track-list changes refresh without discarding an unchanged source. Observed
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
extraction, or parsing fails, the controller returns to live mpv text and the renderer's
conservative short stack heuristic remains the final display fallback.
## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
@@ -84,8 +103,8 @@ coming and prefetching would otherwise idle for the rest of the cue.
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
when text and cue timing match.
- Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- Secondary priming reads mpv `secondary-sub-text` and routes it through the secondary track
controller. A parsed active cue replaces the live text when the selected source is readable.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written.
+33 -39
View File
@@ -125,9 +125,40 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', 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: {
@@ -145,43 +176,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 -14
View File
@@ -129,19 +129,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
return trimmed;
}
async function getCurrentSecondarySubTextForSentenceCard(
mpvClient: MpvClientLike,
): Promise<string | undefined> {
const primaryText = mpvClient.currentSubText;
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.
}
}
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText);
function getCurrentSecondarySubTextForSentenceCard(mpvClient: MpvClientLike): string | undefined {
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, mpvClient.currentSubText);
}
export async function updateLastCardFromClipboard(deps: {
@@ -190,7 +179,7 @@ export async function mineSentenceCard(deps: {
return false;
}
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient);
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient);
return await anki.createSentenceCard(
mpvClient.currentSubText,
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
@@ -527,6 +527,7 @@ import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from './main/runtime/subtitle-prefetch-runtime';
import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track';
import {
createCreateAnilistSetupWindowHandler,
createCreateConfigSettingsWindowHandler,
@@ -1943,7 +1944,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),
@@ -2001,6 +2002,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,
@@ -4383,6 +4402,7 @@ const {
onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -4401,6 +4421,9 @@ const {
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
onSecondarySubtitleChange: (text) => {
secondarySubtitleTrackController.handleLiveText(text);
},
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
emitSubtitlePayload(payload);
@@ -4434,6 +4457,7 @@ const {
appState.activeParsedSubtitleMediaPath,
);
if ((normalizedPath || null) !== previousPath) {
secondarySubtitleTrackController.reset();
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
@@ -4468,6 +4492,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);
@@ -4522,6 +4547,7 @@ const {
subtitlePrefetchService.onSeek(time);
}
lastObservedTimePos = time;
secondarySubtitleTrackController.handleTimePos(time);
},
onFullscreenChange: (fullscreen) => {
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
@@ -4549,6 +4575,13 @@ const {
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
},
onSecondarySubtitleTrackChange: () => {
secondarySubtitleTrackController.handleTrackChange();
secondarySubtitleTrackController.scheduleRefresh(0);
},
onSecondarySubtitleDelayChange: (delay) => {
secondarySubtitleTrackController.handleDelayChange(delay);
},
onSubtitleTrackListChange: (trackList) => {
const diagnostics = buildSubtitleTrackDiagnostics(
lastObservedPrimarySubtitleTrackId,
@@ -4562,6 +4595,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'));
@@ -54,6 +54,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;
@@ -189,6 +191,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
@@ -53,11 +53,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;
@@ -173,6 +176,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
immersionLineDedupGate.reset();
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,
@@ -182,8 +191,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) => {
immersionLineDedupGate.reset();
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;
+12
View File
@@ -1434,6 +1434,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. ';