mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
fix(overlay): deduplicate secondary subtitle rendering (#208)
This commit is contained in:
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'secondary-sub-visibility',
|
||||
'sub-visibility',
|
||||
'sid',
|
||||
'secondary-sid',
|
||||
'secondary-sub-delay',
|
||||
'track-list',
|
||||
];
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. ';
|
||||
|
||||
|
||||
@@ -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(' ')];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user