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 f675ef5b02
commit 1fe9bdc198
7 changed files with 73 additions and 7 deletions
+1
View File
@@ -8,5 +8,6 @@ breaking: true
- Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates.
- Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback.
- Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications.
- Fixed character dictionary sync so duplicate MPV media-path events do not repeat check/ready notifications for the same opened video.
- Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`.
- Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed pause-until-overlay-ready startup on macOS so the initial renderer subtitle snapshot can release the mpv startup gate after the overlay paints annotations.
+4
View File
@@ -6863,6 +6863,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();