fix: reduce prefetched subtitle annotation delay

This commit is contained in:
2026-03-18 23:47:33 -07:00
parent 7a0d7a488b
commit 4d96ebf5c0
16 changed files with 389 additions and 47 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
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.
<!-- SECTION:OUTCOME:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
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.
<!-- SECTION:OUTCOME:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher mpv playback includes `subminer-log_level=<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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
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.
<!-- SECTION:OUTCOME:END -->

View File

@@ -145,7 +145,10 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
}); });
test('buildSubminerScriptOpts includes aniskip payload fields', () => { test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', { const opts = buildSubminerScriptOpts(
'/tmp/SubMiner.AppImage',
'/tmp/subminer.sock',
{
title: "Frieren: Beyond Journey's End", title: "Frieren: Beyond Journey's End",
season: 1, season: 1,
episode: 5, episode: 5,
@@ -154,10 +157,13 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
introStart: 30.5, introStart: 30.5,
introEnd: 62, introEnd: 62,
lookupStatus: 'ready', lookupStatus: 'ready',
}); },
'debug',
);
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/); const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); 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_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/); assert.match(opts, /subminer-aniskip_episode=5/);

View File

@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import type { LogLevel } from './types.js';
import { commandExists } from './util.js'; import { commandExists } from './util.js';
export type AniSkipLookupStatus = export type AniSkipLookupStatus =
@@ -551,11 +552,15 @@ export function buildSubminerScriptOpts(
appPath: string, appPath: string,
socketPath: string, socketPath: string,
aniSkipMetadata: AniSkipMetadata | null, aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info',
): string { ): string {
const parts = [ const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
]; ];
if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
}
if (aniSkipMetadata && aniSkipMetadata.title) { if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`); parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
} }

View File

@@ -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', () => { test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');

View File

@@ -576,7 +576,7 @@ export async function startMpv(
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles) const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
? await resolveAniSkipMetadataForFile(target) ? await resolveAniSkipMetadataForFile(target)
: null; : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel);
if (aniSkipMetadata) { if (aniSkipMetadata) {
log( log(
'debug', 'debug',
@@ -939,9 +939,7 @@ export function launchMpvIdleDetached(
mpvArgs.push(...parseMpvArgString(args.mpvArgs)); mpvArgs.push(...parseMpvArgString(args.mpvArgs));
} }
mpvArgs.push('--idle=yes'); mpvArgs.push('--idle=yes');
mpvArgs.push( mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`);
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);

View File

@@ -17,18 +17,19 @@ test('computePriorityWindow returns next N cues from current position', () => {
const window = computePriorityWindow(cues, 12.0, 5); const window = computePriorityWindow(cues, 12.0, 5);
assert.equal(window.length, 5); assert.equal(window.length, 5);
// Position 12.0 falls during cue 2, so the window starts at cue 3 (startTime >= 12.0). // Position 12.0 falls during cue 2, so the active cue should be warmed first.
assert.equal(window[0]!.text, 'line-3'); assert.equal(window[0]!.text, 'line-2');
assert.equal(window[4]!.text, 'line-7'); assert.equal(window[4]!.text, 'line-6');
}); });
test('computePriorityWindow clamps to remaining cues at end of file', () => { test('computePriorityWindow clamps to remaining cues at end of file', () => {
const cues = makeCues(5); const cues = makeCues(5);
const window = computePriorityWindow(cues, 18.0, 10); const window = computePriorityWindow(cues, 18.0, 10);
// Position 18.0 is during cue 3 (start=15). Only cue 4 is ahead. // Position 18.0 is during cue 3 (start=15), so cue 3 and cue 4 remain.
assert.equal(window.length, 1); assert.equal(window.length, 2);
assert.equal(window[0]!.text, 'line-4'); assert.equal(window[0]!.text, 'line-3');
assert.equal(window[1]!.text, 'line-4');
}); });
test('computePriorityWindow returns empty when past all cues', () => { 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'); 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<void> { function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }

View File

@@ -28,12 +28,12 @@ export function computePriorityWindow(
return []; return [];
} }
// Find the first cue whose start time is >= current position. // Find the first cue whose end time is after the current position.
// This includes cues that start exactly at the current time (they haven't // This includes the currently active cue when playback starts or seeks
// been displayed yet and should be prefetched). // mid-line, while still skipping cues that have already finished.
let startIndex = -1; let startIndex = -1;
for (let i = 0; i < cues.length; i += 1) { for (let i = 0; i < cues.length; i += 1) {
if (cues[i]!.startTime >= currentTimeSeconds) { if (cues[i]!.endTime > currentTimeSeconds) {
startIndex = i; startIndex = i;
break; break;
} }

View File

@@ -190,6 +190,48 @@ test('preCacheTokenization stores entry that is returned on next subtitle change
assert.deepEqual(emitted, [{ text: '予め', tokens: [] }]); 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', () => { test('isCacheFull returns false when cache is below limit', () => {
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: null }), tokenizeSubtitle: async (text) => ({ text, tokens: null }),

View File

@@ -12,9 +12,14 @@ export interface SubtitleProcessingController {
refreshCurrentSubtitle: (textOverride?: string) => void; refreshCurrentSubtitle: (textOverride?: string) => void;
invalidateTokenizationCache: () => void; invalidateTokenizationCache: () => void;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
isCacheFull: () => boolean; 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( export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps, deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController { ): SubtitleProcessingController {
@@ -28,18 +33,19 @@ export function createSubtitleProcessingController(
const now = deps.now ?? (() => Date.now()); const now = deps.now ?? (() => Date.now());
const getCachedTokenization = (text: string): SubtitleData | null => { const getCachedTokenization = (text: string): SubtitleData | null => {
const cached = tokenizationCache.get(text); const cacheKey = normalizeSubtitleCacheKey(text);
const cached = tokenizationCache.get(cacheKey);
if (!cached) { if (!cached) {
return null; return null;
} }
tokenizationCache.delete(text); tokenizationCache.delete(cacheKey);
tokenizationCache.set(text, cached); tokenizationCache.set(cacheKey, cached);
return cached; return cached;
}; };
const setCachedTokenization = (text: string, payload: SubtitleData): void => { const setCachedTokenization = (text: string, payload: SubtitleData): void => {
tokenizationCache.set(text, payload); tokenizationCache.set(normalizeSubtitleCacheKey(text), payload);
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) { while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
const firstKey = tokenizationCache.keys().next().value; const firstKey = tokenizationCache.keys().next().value;
if (firstKey !== undefined) { if (firstKey !== undefined) {
@@ -135,6 +141,17 @@ export function createSubtitleProcessingController(
preCacheTokenization: (text: string, data: SubtitleData) => { preCacheTokenization: (text: string, data: SubtitleData) => {
setCachedTokenization(text, data); setCachedTokenization(text, data);
}, },
consumeCachedSubtitle: (text: string) => {
const cached = getCachedTokenization(text);
if (!cached) {
return null;
}
latestText = text;
lastEmittedText = text;
refreshRequested = false;
return cached;
},
isCacheFull: () => { isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
}, },

View File

@@ -1135,11 +1135,7 @@ function maybeSignalPluginAutoplayReady(
let appTray: Tray | null = null; let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null; let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
const buildSubtitleProcessingControllerMainDepsHandler = function emitSubtitlePayload(payload: SubtitleData): void {
createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text: string) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
emitSubtitle: (payload) => {
appState.currentSubtitleData = payload; appState.currentSubtitleData = payload;
broadcastToOverlayWindows('subtitle:set', payload); broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, { subtitleWsService.broadcast(payload, {
@@ -1153,7 +1149,12 @@ const buildSubtitleProcessingControllerMainDepsHandler =
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}); });
subtitlePrefetchService?.resume(); subtitlePrefetchService?.resume();
}, }
const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text: string) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
emitSubtitle: (payload) => emitSubtitlePayload(payload),
logDebug: (message) => { logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`); logger.debug(`[subtitle-processing] ${message}`);
}, },
@@ -3135,6 +3136,10 @@ const {
broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload); broadcastToOverlayWindows(channel, payload);
}, },
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
emitSubtitlePayload(payload);
},
onSubtitleChange: (text) => { onSubtitleChange: (text) => {
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);

View File

@@ -16,6 +16,7 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({ const handler = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => calls.push(`set:${text}`), setCurrentSubText: (text) => calls.push(`set:${text}`),
getImmediateSubtitlePayload: () => null,
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`), broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
onSubtitleChange: (text) => calls.push(`process:${text}`), onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => calls.push('presence'), 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']); 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', () => { test('subtitle ass change handler updates state and broadcasts', () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createHandleMpvSubtitleAssChangeHandler({ const handler = createHandleMpvSubtitleAssChangeHandler({

View File

@@ -1,12 +1,24 @@
import type { SubtitleData } from '../../types';
export function createHandleMpvSubtitleChangeHandler(deps: { export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => 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; onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
}) { }) {
return ({ text }: { text: string }): void => { return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text); 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.onSubtitleChange(text);
deps.refreshDiscordPresence(); deps.refreshDiscordPresence();
}; };

View File

@@ -1,3 +1,4 @@
import type { SubtitleData } from '../../types';
import { import {
createBindMpvClientEventHandlers, createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler, createHandleMpvConnectionChangeHandler,
@@ -35,7 +36,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logSubtitleTimingError: (message: string, error: unknown) => void; logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => 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; onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
@@ -89,6 +92,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
}); });
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({ const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => deps.setCurrentSubText(text), setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text), onSubtitleChange: (text) => deps.onSubtitleChange(text),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),

View File

@@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeRunAnilistPostWatchUpdate: () => Promise<void>; maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void; logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void; onSubtitleTrackChange?: (sid: number | null) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
@@ -102,7 +104,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
setCurrentSubText: (text: string) => { setCurrentSubText: (text: string) => {
deps.appState.currentSubText = text; 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), deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text), onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
onSubtitleTrackChange: deps.onSubtitleTrackChange onSubtitleTrackChange: deps.onSubtitleTrackChange