From f335b26fe39311d06afb2bd0ccf4773e80870422 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 17 Aug 2026 23:37:09 -0700 Subject: [PATCH] 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 --- changes/fix-secondary-subtitle-duplication.md | 4 + docs/architecture/subtitle-overlay-priming.md | 25 +- src/core/services/mining.test.ts | 72 +++-- src/core/services/mining.ts | 17 +- src/core/services/mpv-properties.ts | 2 + src/core/services/mpv-protocol.test.ts | 17 +- src/core/services/mpv-protocol.ts | 20 ++ src/core/services/mpv.ts | 8 + src/main.ts | 36 ++- .../runtime/mpv-client-event-bindings.test.ts | 4 + src/main/runtime/mpv-client-event-bindings.ts | 6 + .../runtime/mpv-main-event-bindings.test.ts | 7 + src/main/runtime/mpv-main-event-bindings.ts | 4 + .../runtime/mpv-main-event-main-deps.test.ts | 9 + src/main/runtime/mpv-main-event-main-deps.ts | 18 +- .../runtime/secondary-subtitle-track.test.ts | 250 ++++++++++++++++++ src/main/runtime/secondary-subtitle-track.ts | 233 ++++++++++++++++ .../runtime/subtitle-prefetch-runtime.test.ts | 28 ++ src/main/runtime/subtitle-prefetch-runtime.ts | 13 +- src/renderer/subtitle-render.test.ts | 12 + src/renderer/subtitle-render.ts | 16 +- 21 files changed, 738 insertions(+), 63 deletions(-) create mode 100644 changes/fix-secondary-subtitle-duplication.md create mode 100644 src/main/runtime/secondary-subtitle-track.test.ts create mode 100644 src/main/runtime/secondary-subtitle-track.ts diff --git a/changes/fix-secondary-subtitle-duplication.md b/changes/fix-secondary-subtitle-duplication.md new file mode 100644 index 00000000..07682311 --- /dev/null +++ b/changes/fix-secondary-subtitle-duplication.md @@ -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. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index e5ed4fc9..6acd5602 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -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. diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 7431ed6b..3e24c617 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -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: () => {}, }); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 9dac45de..cdde3b75 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -129,19 +129,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string | return trimmed; } -async function getCurrentSecondarySubTextForSentenceCard( - mpvClient: MpvClientLike, -): Promise { - 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, diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index c20db48e..ae7f2b78 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'secondary-sub-visibility', 'sub-visibility', 'sid', + 'secondary-sid', + 'secondary-sub-delay', 'track-list', ]; diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 11991db3..9878e383 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -63,6 +63,8 @@ function createDeps(overrides: Partial = {}): { 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,25 @@ 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 enforces sub-visibility hidden when overlay suppression is enabled', async () => { diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index c8c8a22f..9077913b 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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; @@ -282,6 +284,24 @@ export async function dispatchMpvProtocolMessage( ? Number(msg.data) : null; deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(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.isFinite(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, diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index a4e8c96c..271e9735 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -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); }, diff --git a/src/main.ts b/src/main.ts index 7655d20d..252cd025 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); }, diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 74bf63f1..f61e5274 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -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', diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 8132c69a..cb0d7c83 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -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); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 570ab696..58db5288 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -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')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 4fcbeba9..1b48d783 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -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, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 238e21c3..32d96c3e 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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')); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 5acd36a2..49482024 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -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); diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts new file mode 100644 index 00000000..c4f5d995 --- /dev/null +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -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((resolve) => { + notifyResolveStarted = resolve; + }); + const resolveGate = new Promise((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); +}); diff --git a/src/main/runtime/secondary-subtitle-track.ts b/src/main/runtime/secondary-subtitle-track.ts new file mode 100644 index 00000000..0ef68b50 --- /dev/null +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -0,0 +1,233 @@ +import type { SubtitleCue } from '../../types/subtitle'; + +type SecondarySubtitleMpvClient = { + connected?: boolean; + requestProperty: (name: string) => Promise; +}; + +type ResolvedSubtitleSource = { + path: string; + sourceKey: string; + cleanup?: () => Promise; +}; + +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; + return track.type === 'sub' && trackId(track.id) === sid; + }) as Record | 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(); + 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; + loadSubtitleSourceText: (source: string) => Promise; + 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 | 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 => { + 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, + }; +} diff --git a/src/main/runtime/subtitle-prefetch-runtime.test.ts b/src/main/runtime/subtitle-prefetch-runtime.test.ts index 385b3365..a5780680 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.test.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.test.ts @@ -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); +}); diff --git a/src/main/runtime/subtitle-prefetch-runtime.ts b/src/main/runtime/subtitle-prefetch-runtime.ts index 91ec827c..098697a9 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.ts @@ -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 => { 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; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index d8ab072a..8d60a21a 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -1434,6 +1434,18 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']); }); +test('prepareSecondarySubtitleLines collapses a short stack of repeated ASS layers', () => { + const layeredSign = ['Your', 'Your', 'Your', 'Your', 'mosaic'].join('\\N'); + + assert.deepEqual(prepareSecondarySubtitleLines(layeredSign), ['Your mosaic']); +}); + +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. '; diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 8b63c004..95d70d2f 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -657,12 +657,24 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void // hover-pause band into a full-screen trap. const KARAOKE_MIN_LINE_COUNT = 8; const KARAOKE_MAX_MEDIAN_LINE_LENGTH = 4; +const LAYERED_REPEAT_MIN_LINE_COUNT = 4; +const LAYERED_REPEAT_MIN_COUNT = 3; function isKaraokeLikeLineSet(lines: string[]): boolean { - if (lines.length < KARAOKE_MIN_LINE_COUNT) return false; const lengths = lines.map((line) => line.length).sort((a, b) => a - b); const median = lengths[Math.floor(lengths.length / 2)] ?? 0; - return median <= KARAOKE_MAX_MEDIAN_LINE_LENGTH; + if (median > KARAOKE_MAX_MEDIAN_LINE_LENGTH) return false; + + if (lines.length >= KARAOKE_MIN_LINE_COUNT) return true; + if (lines.length < LAYERED_REPEAT_MIN_LINE_COUNT) return false; + + const repeatCounts = new Map(); + for (const line of lines) { + const count = (repeatCounts.get(line) ?? 0) + 1; + if (count >= LAYERED_REPEAT_MIN_COUNT) return true; + repeatCounts.set(line, count); + } + return false; } export function prepareSecondarySubtitleLines(text: string): string[] {