fix(startup): signal autoplay gate from subtitle resolve; dedupe dict sy

- Add onResolvedSubtitle callback to resolveCurrentSubtitleForRenderer so the startup overlay-ready gate fires after the initial subtitle resolves
- Guard scheduleCharacterDictionarySync behind a last-path check so duplicate MPV media-path events don't re-trigger sync for the same video
This commit is contained in:
2026-06-05 22:04:20 -07:00
parent 0f8370a3a9
commit ef914a321f
7 changed files with 73 additions and 7 deletions
+4
View File
@@ -6831,6 +6831,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
tokenizeSubtitle: tokenizeSubtitleForCurrent
? (text) => tokenizeSubtitleForCurrent(text)
: undefined,
onResolvedSubtitle: (payload) => {
appState.currentSubtitleData = payload;
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
},
});
},
getCurrentSubtitleRaw: () => appState.currentSubText,
@@ -62,6 +62,21 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
assert.deepEqual(payload.tokens, [{ text: '新' }]);
});
test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => {
const resolvedPayloads: SubtitleData[] = [];
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '起動字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '起' } as never] }),
onResolvedSubtitle: (resolved) => {
resolvedPayloads.push(resolved);
},
});
assert.deepEqual(resolvedPayloads, [payload]);
});
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
const calls: string[] = [];
+11 -4
View File
@@ -10,13 +10,20 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubtitleData: SubtitleData | null;
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
onResolvedSubtitle?: (payload: SubtitleData) => void;
}): Promise<SubtitleData> {
const resolve = (payload: SubtitleData): SubtitleData => {
const timedPayload = deps.withCurrentSubtitleTiming(payload);
deps.onResolvedSubtitle?.(timedPayload);
return timedPayload;
};
if (deps.currentSubtitleData?.text === deps.currentSubText) {
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
return resolve(deps.currentSubtitleData);
}
if (!deps.currentSubText.trim()) {
return deps.withCurrentSubtitleTiming({
return resolve({
text: deps.currentSubText,
tokens: null,
});
@@ -24,10 +31,10 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
if (tokenized) {
return deps.withCurrentSubtitleTiming(tokenized);
return resolve(tokenized);
}
return deps.withCurrentSubtitleTiming({
return resolve({
text: deps.currentSubText,
tokens: null,
});
@@ -183,6 +183,34 @@ test('media path change handler signals autoplay readiness from warm media path'
]);
});
test('media path change handler schedules character dictionary once per media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '/tmp/video.mkv' });
handler({ path: '/tmp/video.mkv' });
handler({ path: '/tmp/next-video.mkv' });
handler({ path: '' });
handler({ path: '/tmp/video.mkv' });
assert.deepEqual(
calls.filter((call) => call === 'dict-sync'),
['dict-sync', 'dict-sync', 'dict-sync'],
);
});
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
+10 -3
View File
@@ -74,9 +74,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
refreshDiscordPresence: () => void;
}) {
let lastCharacterDictionarySyncMediaPath: string | null = null;
return ({ path }: { path: string | null }): void => {
const normalizedPath = typeof path === 'string' ? path : '';
if (!normalizedPath) {
const trimmedPath = normalizedPath.trim();
if (!trimmedPath) {
lastCharacterDictionarySyncMediaPath = null;
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
}
deps.updateCurrentMediaPath(normalizedPath);
@@ -92,9 +96,12 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.ensureAnilistMediaGuess(mediaKey);
}
deps.syncImmersionMediaState();
if (normalizedPath.trim().length > 0) {
if (trimmedPath.length > 0) {
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
deps.scheduleCharacterDictionarySync?.();
if (trimmedPath !== lastCharacterDictionarySyncMediaPath) {
lastCharacterDictionarySyncMediaPath = trimmedPath;
deps.scheduleCharacterDictionarySync?.();
}
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
}
deps.refreshDiscordPresence();