From 4d96ebf5c0d54eb5091f9ab99f57e3cebd8a868d Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 18 Mar 2026 23:47:33 -0700 Subject: [PATCH] fix: reduce prefetched subtitle annotation delay --- ...ache-key-mismatch-and-active-cue-window.md | 43 ++++++++++++ ...in-subtitle-flash-on-prefetch-cache-hit.md | 45 ++++++++++++ ...r-log-level-into-mpv-plugin-script-opts.md | 45 ++++++++++++ launcher/aniskip-metadata.test.ts | 26 ++++--- launcher/aniskip-metadata.ts | 5 ++ launcher/main.test.ts | 70 +++++++++++++++++++ launcher/mpv.ts | 6 +- src/core/services/subtitle-prefetch.test.ts | 23 ++++-- src/core/services/subtitle-prefetch.ts | 8 +-- .../subtitle-processing-controller.test.ts | 42 +++++++++++ .../subtitle-processing-controller.ts | 25 +++++-- src/main.ts | 35 ++++++---- .../runtime/mpv-main-event-actions.test.ts | 30 ++++++++ src/main/runtime/mpv-main-event-actions.ts | 16 ++++- src/main/runtime/mpv-main-event-bindings.ts | 7 +- src/main/runtime/mpv-main-event-main-deps.ts | 10 ++- 16 files changed, 389 insertions(+), 47 deletions(-) create mode 100644 backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md create mode 100644 backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md create mode 100644 backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md diff --git a/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md b/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md new file mode 100644 index 0000000..bff32ca --- /dev/null +++ b/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md @@ -0,0 +1,43 @@ +--- +id: TASK-196 +title: Fix subtitle prefetch cache-key mismatch and active-cue window +status: Done +assignee: [] +created_date: '2026-03-18 16:05' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts + - /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts +documentation: [] +priority: high +--- + +## Description + + +Investigate and fix file-backed subtitle annotation latency where prefetch should warm upcoming lines but live playback still tokenizes each subtitle line. Likely causes: cache-key mismatch between parsed cue text and mpv `sub-text`, and priority-window selection skipping the currently active cue during mid-line starts/seeks. + + +## Acceptance Criteria + +- [x] #1 Prefetched subtitle entries are reused when live subtitle text differs only by normalization details such as ASS `\N`, newline collapsing, or surrounding whitespace. +- [x] #2 Priority-window selection includes the currently active cue when playback starts or seeks into the middle of a cue. +- [x] #3 Regression tests cover the cache-hit normalization path and active-cue priority-window behavior. +- [x] #4 Verification covers the touched prefetch/controller lane. + + +## Implementation Plan + + +1. Add failing regression tests in `subtitle-processing-controller.test.ts` and `subtitle-prefetch.test.ts`. +2. Normalize cache keys in the subtitle processing controller so prefetch/live paths share keys. +3. Adjust prefetch priority-window selection to include the active cue. +4. Run targeted tests, then SubMiner verification lane for touched files. + + +## Outcome + + +Normalized subtitle cache keys inside the processing controller so prefetched ASS/VTT/live subtitle text variants reuse the same cache entry, and changed priority-window selection to include the currently active cue based on cue end time. Added regression coverage for both paths and verified the change with the `core` lane. + diff --git a/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md b/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md new file mode 100644 index 0000000..8414d1e --- /dev/null +++ b/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md @@ -0,0 +1,45 @@ +--- +id: TASK-197 +title: Eliminate per-line plain subtitle flash on prefetch cache hit +status: Done +assignee: [] +created_date: '2026-03-18 16:28' +labels: [] +dependencies: + - TASK-196 +references: + - /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts + - /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts + - /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts +documentation: [] +priority: high +--- + +## Description + + +Remove the remaining small per-line subtitle annotation delay after prefetch warmup by avoiding the unconditional plain-subtitle broadcast on mpv subtitle-change events when a cached annotated payload already exists. + + +## Acceptance Criteria + +- [x] #1 On a subtitle cache hit, the mpv subtitle-change path can emit annotated subtitle payload synchronously instead of first broadcasting `tokens: null`. +- [x] #2 Cache-miss behavior still preserves immediate plain-text subtitle display while async tokenization runs. +- [x] #3 Regression tests cover the controller cache-consume path and the mpv subtitle-change handler cache-hit branch. +- [x] #4 Verification covers the touched core/runtime lane. + + +## Implementation Plan + + +1. Add failing tests for controller cache consumption and mpv subtitle-change immediate annotated emission. +2. Add a controller method that consumes cached subtitle payload synchronously while updating internal latest/emitted state. +3. Wire the mpv subtitle-change handler to use the immediate cached payload when present, falling back to the existing plain-text path on misses. +4. Run focused tests and the cheapest sufficient verification lane. + + +## Outcome + + +Added `consumeCachedSubtitle` to the subtitle processing controller so cache hits can be claimed synchronously without reprocessing, then wired the mpv subtitle-change handler to emit cached annotated payloads immediately while preserving the existing plain-text fallback for misses. Verified with focused unit tests plus the `runtime-compat` lane. + diff --git a/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md b/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md new file mode 100644 index 0000000..c7c05ae --- /dev/null +++ b/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md @@ -0,0 +1,45 @@ +--- +id: TASK-199 +title: Forward launcher log level into mpv plugin script opts +status: Done +assignee: [] +created_date: '2026-03-18 21:16' +labels: [] +dependencies: + - TASK-198 +references: + - /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.ts + - /home/sudacode/projects/japanese/SubMiner/launcher/mpv.ts + - /home/sudacode/projects/japanese/SubMiner/launcher/main.test.ts + - /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.test.ts +documentation: [] +priority: medium +--- + +## Description + + +Make `subminer --log-level=debug ...` reach the mpv plugin auto-start path by forwarding the launcher log level into `--script-opts`, so plugin-started overlay and texthooker subprocesses inherit debug logging. + + +## Acceptance Criteria + +- [x] #1 Launcher mpv playback includes `subminer-log_level=` in `--script-opts` when a non-info CLI log level is used. +- [x] #2 Detached idle mpv launch uses the same script-opt forwarding. +- [x] #3 Regression tests cover launcher script-opt forwarding. + + +## Implementation Plan + + +1. Add a failing launcher regression test that captures mpv argv and expects `subminer-log_level=debug` inside `--script-opts`. +2. Extend the shared script-opt builder to accept launcher log level and emit `subminer-log_level` for non-info runs. +3. Reuse that builder in both normal mpv playback and detached idle mpv launch. +4. Run focused launcher tests and launcher-plugin verification. + + +## Outcome + + +Forwarded launcher log level into mpv plugin script opts via the shared builder and reused that builder for idle mpv launch. `subminer --log-level=debug ...` now gives the plugin `opts.log_level=debug`, so auto-started overlay and texthooker subprocesses include `--log-level debug` and the tokenizer timing logs can actually appear in the app log. + diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts index b159031..e003177 100644 --- a/launcher/aniskip-metadata.test.ts +++ b/launcher/aniskip-metadata.test.ts @@ -145,19 +145,25 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses' }); test('buildSubminerScriptOpts includes aniskip payload fields', () => { - const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', { - title: "Frieren: Beyond Journey's End", - season: 1, - episode: 5, - source: 'guessit', - malId: 1234, - introStart: 30.5, - introEnd: 62, - lookupStatus: 'ready', - }); + const opts = buildSubminerScriptOpts( + '/tmp/SubMiner.AppImage', + '/tmp/subminer.sock', + { + title: "Frieren: Beyond Journey's End", + season: 1, + episode: 5, + source: 'guessit', + malId: 1234, + introStart: 30.5, + introEnd: 62, + lookupStatus: 'ready', + }, + 'debug', + ); const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); + assert.match(opts, /subminer-log_level=debug/); assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/); assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_episode=5/); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 22653ba..047b03e 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import type { LogLevel } from './types.js'; import { commandExists } from './util.js'; export type AniSkipLookupStatus = @@ -551,11 +552,15 @@ export function buildSubminerScriptOpts( appPath: string, socketPath: string, aniSkipMetadata: AniSkipMetadata | null, + logLevel: LogLevel = 'info', ): string { const parts = [ `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, ]; + if (logLevel !== 'info') { + parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`); + } if (aniSkipMetadata && aniSkipMetadata.title) { parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`); } diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 34a3bbb..83751a0 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -387,6 +387,76 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con }); }); +test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const binDir = path.join(root, 'bin'); + const appPath = path.join(root, 'fake-subminer.sh'); + const videoPath = path.join(root, 'movie.mkv'); + const mpvArgsPath = path.join(root, 'mpv-args.txt'); + const socketPath = path.join(root, 'mpv.sock'); + const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.writeFileSync(videoPath, 'fake video content'); + fs.writeFileSync( + path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), + JSON.stringify({ + version: 1, + status: 'completed', + completedAt: '2026-03-08T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + }), + ); + fs.writeFileSync( + path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), + `socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`, + ); + fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(appPath, 0o755); + + fs.writeFileSync( + path.join(binDir, 'mpv'), + `#!/bin/sh +set -eu +printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" +socket_path="" +for arg in "$@"; do + case "$arg" in + --input-ipc-server=*) + socket_path="\${arg#--input-ipc-server=}" + ;; + esac +done +${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" +`, + 'utf8', + ); + fs.chmodSync(path.join(binDir, 'mpv'), 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_MPV_ARGS: mpvArgsPath, + }; + const result = runLauncher(['--log-level', 'debug', videoPath], env); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match( + fs.readFileSync(mpvArgsPath, 'utf8'), + /--script-opts=.*subminer-log_level=debug/, + ); + }); +}); + test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index d3bd197..a487b09 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -576,7 +576,7 @@ export async function startMpv( const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles) ? await resolveAniSkipMetadataForFile(target) : null; - const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); + const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel); if (aniSkipMetadata) { log( 'debug', @@ -939,9 +939,7 @@ export function launchMpvIdleDetached( mpvArgs.push(...parseMpvArgString(args.mpvArgs)); } mpvArgs.push('--idle=yes'); - mpvArgs.push( - `--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`, - ); + mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`); mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(`--input-ipc-server=${socketPath}`); const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index 45c2ab3..57f7df3 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -17,18 +17,19 @@ test('computePriorityWindow returns next N cues from current position', () => { const window = computePriorityWindow(cues, 12.0, 5); assert.equal(window.length, 5); - // Position 12.0 falls during cue 2, so the window starts at cue 3 (startTime >= 12.0). - assert.equal(window[0]!.text, 'line-3'); - assert.equal(window[4]!.text, 'line-7'); + // Position 12.0 falls during cue 2, so the active cue should be warmed first. + assert.equal(window[0]!.text, 'line-2'); + assert.equal(window[4]!.text, 'line-6'); }); test('computePriorityWindow clamps to remaining cues at end of file', () => { const cues = makeCues(5); const window = computePriorityWindow(cues, 18.0, 10); - // Position 18.0 is during cue 3 (start=15). Only cue 4 is ahead. - assert.equal(window.length, 1); - assert.equal(window[0]!.text, 'line-4'); + // Position 18.0 is during cue 3 (start=15), so cue 3 and cue 4 remain. + assert.equal(window.length, 2); + assert.equal(window[0]!.text, 'line-3'); + assert.equal(window[1]!.text, 'line-4'); }); test('computePriorityWindow returns empty when past all cues', () => { @@ -45,6 +46,16 @@ test('computePriorityWindow at position 0 returns first N cues', () => { assert.equal(window[0]!.text, 'line-0'); }); +test('computePriorityWindow includes the active cue when current position is mid-line', () => { + const cues = makeCues(20); + const window = computePriorityWindow(cues, 18.0, 3); + + assert.equal(window.length, 3); + assert.equal(window[0]!.text, 'line-3'); + assert.equal(window[1]!.text, 'line-4'); + assert.equal(window[2]!.text, 'line-5'); +}); + function flushMicrotasks(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts index 4258e94..eb0eb9a 100644 --- a/src/core/services/subtitle-prefetch.ts +++ b/src/core/services/subtitle-prefetch.ts @@ -28,12 +28,12 @@ export function computePriorityWindow( return []; } - // Find the first cue whose start time is >= current position. - // This includes cues that start exactly at the current time (they haven't - // been displayed yet and should be prefetched). + // Find the first cue whose end time is after the current position. + // This includes the currently active cue when playback starts or seeks + // mid-line, while still skipping cues that have already finished. let startIndex = -1; for (let i = 0; i < cues.length; i += 1) { - if (cues[i]!.startTime >= currentTimeSeconds) { + if (cues[i]!.endTime > currentTimeSeconds) { startIndex = i; break; } diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index cc6095b..7a32549 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -190,6 +190,48 @@ test('preCacheTokenization stores entry that is returned on next subtitle change assert.deepEqual(emitted, [{ text: '予め', tokens: [] }]); }); +test('preCacheTokenization reuses normalized subtitle text across ASS linebreak variants', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.preCacheTokenization('一行目\\N二行目', { text: '一行目\n二行目', tokens: [] }); + controller.onSubtitleChange('一行目\n二行目'); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 0, 'should not call tokenize when normalized text matches'); + assert.deepEqual(emitted, [{ text: '一行目\n二行目', tokens: [] }]); +}); + +test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing same line', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.preCacheTokenization('猫\\Nです', { text: '猫\nです', tokens: [] }); + + const immediate = controller.consumeCachedSubtitle('猫\nです'); + assert.deepEqual(immediate, { text: '猫\nです', tokens: [] }); + + controller.onSubtitleChange('猫\nです'); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 0, 'same cached subtitle should not reprocess after immediate consume'); + assert.deepEqual(emitted, []); +}); + test('isCacheFull returns false when cache is below limit', () => { const controller = createSubtitleProcessingController({ tokenizeSubtitle: async (text) => ({ text, tokens: null }), diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index aebe820..6bb1628 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -12,9 +12,14 @@ export interface SubtitleProcessingController { refreshCurrentSubtitle: (textOverride?: string) => void; invalidateTokenizationCache: () => void; preCacheTokenization: (text: string, data: SubtitleData) => void; + consumeCachedSubtitle: (text: string) => SubtitleData | null; isCacheFull: () => boolean; } +function normalizeSubtitleCacheKey(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim(); +} + export function createSubtitleProcessingController( deps: SubtitleProcessingControllerDeps, ): SubtitleProcessingController { @@ -28,18 +33,19 @@ export function createSubtitleProcessingController( const now = deps.now ?? (() => Date.now()); const getCachedTokenization = (text: string): SubtitleData | null => { - const cached = tokenizationCache.get(text); + const cacheKey = normalizeSubtitleCacheKey(text); + const cached = tokenizationCache.get(cacheKey); if (!cached) { return null; } - tokenizationCache.delete(text); - tokenizationCache.set(text, cached); + tokenizationCache.delete(cacheKey); + tokenizationCache.set(cacheKey, cached); return cached; }; const setCachedTokenization = (text: string, payload: SubtitleData): void => { - tokenizationCache.set(text, payload); + tokenizationCache.set(normalizeSubtitleCacheKey(text), payload); while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) { const firstKey = tokenizationCache.keys().next().value; if (firstKey !== undefined) { @@ -135,6 +141,17 @@ export function createSubtitleProcessingController( preCacheTokenization: (text: string, data: SubtitleData) => { setCachedTokenization(text, data); }, + consumeCachedSubtitle: (text: string) => { + const cached = getCachedTokenization(text); + if (!cached) { + return null; + } + + latestText = text; + lastEmittedText = text; + refreshRequested = false; + return cached; + }, isCacheFull: () => { return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; }, diff --git a/src/main.ts b/src/main.ts index d080992..491fe18 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1135,25 +1135,26 @@ function maybeSignalPluginAutoplayReady( let appTray: Tray | null = null; let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; +function emitSubtitlePayload(payload: SubtitleData): void { + appState.currentSubtitleData = payload; + broadcastToOverlayWindows('subtitle:set', payload); + subtitleWsService.broadcast(payload, { + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }); + annotationSubtitleWsService.broadcast(payload, { + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }); + subtitlePrefetchService?.resume(); +} const buildSubtitleProcessingControllerMainDepsHandler = createBuildSubtitleProcessingControllerMainDepsHandler({ tokenizeSubtitle: async (text: string) => tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, - emitSubtitle: (payload) => { - appState.currentSubtitleData = payload; - broadcastToOverlayWindows('subtitle:set', payload); - subtitleWsService.broadcast(payload, { - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }); - annotationSubtitleWsService.broadcast(payload, { - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }); - subtitlePrefetchService?.resume(); - }, + emitSubtitle: (payload) => emitSubtitlePayload(payload), logDebug: (message) => { logger.debug(`[subtitle-processing] ${message}`); }, @@ -3135,6 +3136,10 @@ const { broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows(channel, payload); }, + getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text), + emitImmediateSubtitle: (payload) => { + emitSubtitlePayload(payload); + }, onSubtitleChange: (text) => { subtitlePrefetchService?.pause(); subtitleProcessingController.onSubtitleChange(text); diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 5f57713..0f406a8 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -16,6 +16,7 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => { const calls: string[] = []; const handler = createHandleMpvSubtitleChangeHandler({ setCurrentSubText: (text) => calls.push(`set:${text}`), + getImmediateSubtitlePayload: () => null, broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`), onSubtitleChange: (text) => calls.push(`process:${text}`), refreshDiscordPresence: () => calls.push('presence'), @@ -25,6 +26,35 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => { assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']); }); +test('subtitle change handler broadcasts cached annotated payload immediately when available', () => { + const payloads: Array<{ text: string; tokens: unknown[] | null }> = []; + const calls: string[] = []; + const handler = createHandleMpvSubtitleChangeHandler({ + setCurrentSubText: (text) => calls.push(`set:${text}`), + getImmediateSubtitlePayload: (text) => { + calls.push(`lookup:${text}`); + return { text, tokens: [] }; + }, + broadcastSubtitle: (payload) => { + payloads.push(payload); + calls.push(`broadcast:${payload.tokens === null ? 'plain' : 'annotated'}`); + }, + onSubtitleChange: (text) => calls.push(`process:${text}`), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ text: 'line' }); + + assert.deepEqual(payloads, [{ text: 'line', tokens: [] }]); + assert.deepEqual(calls, [ + 'set:line', + 'lookup:line', + 'broadcast:annotated', + 'process:line', + 'presence', + ]); +}); + test('subtitle ass change handler updates state and broadcasts', () => { const calls: string[] = []; const handler = createHandleMpvSubtitleAssChangeHandler({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 07b81ed..0d098c1 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -1,12 +1,24 @@ +import type { SubtitleData } from '../../types'; + export function createHandleMpvSubtitleChangeHandler(deps: { setCurrentSubText: (text: string) => void; - broadcastSubtitle: (payload: { text: string; tokens: null }) => void; + getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; + emitImmediateSubtitle?: (payload: SubtitleData) => void; + broadcastSubtitle: (payload: SubtitleData) => void; onSubtitleChange: (text: string) => void; refreshDiscordPresence: () => void; }) { return ({ text }: { text: string }): void => { deps.setCurrentSubText(text); - deps.broadcastSubtitle({ text, tokens: null }); + const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null; + if (immediatePayload) { + (deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload); + } else { + deps.broadcastSubtitle({ + text, + tokens: null, + }); + } deps.onSubtitleChange(text); deps.refreshDiscordPresence(); }; diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 391c6e7..88dc7d7 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -1,3 +1,4 @@ +import type { SubtitleData } from '../../types'; import { createBindMpvClientEventHandlers, createHandleMpvConnectionChangeHandler, @@ -35,7 +36,9 @@ export function createBindMpvMainEventHandlersHandler(deps: { logSubtitleTimingError: (message: string, error: unknown) => void; setCurrentSubText: (text: string) => void; - broadcastSubtitle: (payload: { text: string; tokens: null }) => void; + getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; + emitImmediateSubtitle?: (payload: SubtitleData) => void; + broadcastSubtitle: (payload: SubtitleData) => void; onSubtitleChange: (text: string) => void; refreshDiscordPresence: () => void; @@ -89,6 +92,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { }); const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({ setCurrentSubText: (text) => deps.setCurrentSubText(text), + getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null, + emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload), broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), onSubtitleChange: (text) => deps.onSubtitleChange(text), refreshDiscordPresence: () => deps.refreshDiscordPresence(), diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 57b56c7..ac1393e 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { maybeRunAnilistPostWatchUpdate: () => Promise; logSubtitleTimingError: (message: string, error: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; + emitImmediateSubtitle?: (payload: SubtitleData) => void; onSubtitleChange: (text: string) => void; onSubtitleTrackChange?: (sid: number | null) => void; onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; @@ -102,7 +104,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { setCurrentSubText: (text: string) => { deps.appState.currentSubText = text; }, - broadcastSubtitle: (payload: { text: string; tokens: null }) => + getImmediateSubtitlePayload: deps.getImmediateSubtitlePayload + ? (text: string) => deps.getImmediateSubtitlePayload!(text) + : undefined, + emitImmediateSubtitle: deps.emitImmediateSubtitle + ? (payload: SubtitleData) => deps.emitImmediateSubtitle!(payload) + : undefined, + broadcastSubtitle: (payload: SubtitleData) => deps.broadcastToOverlayWindows('subtitle:set', payload), onSubtitleChange: (text: string) => deps.onSubtitleChange(text), onSubtitleTrackChange: deps.onSubtitleTrackChange