fix(osd): show subtitle-annotation loading status during tokenization

This commit is contained in:
2026-02-28 15:38:03 -08:00
parent dac9a3429a
commit bf333c7c08
4 changed files with 207 additions and 7 deletions

View File

@@ -2322,6 +2322,11 @@ const {
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup: () =>
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
showMpvOsd: (message: string) => showMpvOsd(message),
shouldShowOsdNotification: () => {
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
return type === 'osd' || type === 'both';
},
}, },
}, },
warmups: { warmups: {
@@ -2366,6 +2371,7 @@ const {
); );
}, },
startJellyfinRemoteSession: () => startJellyfinRemoteSession(), startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
logDebug: (message) => logger.debug(message),
}, },
}, },
}); });

View File

@@ -87,6 +87,7 @@ export type MpvRuntimeComposerResult<
tokenizeSubtitle: (text: string) => Promise<TTokenizedSubtitle>; tokenizeSubtitle: (text: string) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheck: () => Promise<void>; createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>; prewarmSubtitleDictionaries: () => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>; launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>;
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>; startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
}>; }>;
@@ -132,12 +133,23 @@ export function composeMpvRuntimeHandlers<
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler( const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
options.tokenizer.prewarmSubtitleDictionariesMainDeps, options.tokenizer.prewarmSubtitleDictionariesMainDeps,
); );
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => { let tokenizationWarmupInFlight: Promise<void> | null = null;
const startTokenizationWarmups = (): Promise<void> => {
if (!tokenizationWarmupInFlight) {
tokenizationWarmupInFlight = (async () => {
await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded(); await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded();
if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) { if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) {
await createMecabTokenizerAndCheck().catch(() => {}); await createMecabTokenizerAndCheck().catch(() => {});
} }
await prewarmSubtitleDictionaries(); await prewarmSubtitleDictionaries({ showLoadingOsd: true });
})().finally(() => {
tokenizationWarmupInFlight = null;
});
}
return tokenizationWarmupInFlight;
};
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
await startTokenizationWarmups();
return options.tokenizer.tokenizeSubtitle( return options.tokenizer.tokenizeSubtitle(
text, text,
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()), options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
@@ -165,6 +177,7 @@ export function composeMpvRuntimeHandlers<
tokenizeSubtitle, tokenizeSubtitle,
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
startTokenizationWarmups,
launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task), launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task),
startBackgroundWarmups: () => startBackgroundWarmups(), startBackgroundWarmups: () => startBackgroundWarmups(),
}; };

View File

@@ -6,6 +6,17 @@ import {
createPrewarmSubtitleDictionariesMainHandler, createPrewarmSubtitleDictionariesMainHandler,
} from './subtitle-tokenization-main-deps'; } from './subtitle-tokenization-main-deps';
function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
} {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
test('tokenizer deps builder records known-word lookups and maps readers', () => { test('tokenizer deps builder records known-word lookups and maps readers', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildTokenizerDepsMainHandler({ const deps = createBuildTokenizerDepsMainHandler({
@@ -77,3 +88,93 @@ test('dictionary prewarm runs both dictionary loaders', async () => {
await prewarm(); await prewarm();
assert.deepEqual(calls.sort(), ['freq', 'jlpt']); assert.deepEqual(calls.sort(), ['freq', 'jlpt']);
}); });
test('dictionary prewarm shows OSD spinner while loading and completion when loaded', async () => {
const osdMessages: string[] = [];
const clearedTimers: unknown[] = [];
let tick: (() => void) | null = null;
const jlptDeferred = createDeferred();
const freqDeferred = createDeferred();
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => freqDeferred.promise,
shouldShowOsdNotification: () => true,
showMpvOsd: (message) => {
osdMessages.push(message);
},
setInterval: (callback) => {
tick = callback;
return 1;
},
clearInterval: (timer) => {
clearedTimers.push(timer);
},
});
const prewarmPromise = prewarm({ showLoadingOsd: true });
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
if (!tick) {
throw new Error('expected loading spinner tick callback');
}
const tickCallback: () => void = tick;
tickCallback();
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Loading subtitle annotations /']);
jlptDeferred.resolve();
freqDeferred.resolve();
await prewarmPromise;
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Loading subtitle annotations /',
'Subtitle annotations loaded',
]);
assert.deepEqual(clearedTimers, [1]);
});
test('dictionary prewarm can show OSD while awaiting background-started load', async () => {
const osdMessages: string[] = [];
const jlptDeferred = createDeferred();
const freqDeferred = createDeferred();
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => freqDeferred.promise,
shouldShowOsdNotification: () => true,
showMpvOsd: (message) => {
osdMessages.push(message);
},
setInterval: () => 1,
clearInterval: () => undefined,
});
const backgroundWarmupPromise = prewarm();
const tokenizationWarmupPromise = prewarm({ showLoadingOsd: true });
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
jlptDeferred.resolve();
freqDeferred.resolve();
await backgroundWarmupPromise;
await tokenizationWarmupPromise;
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
});
test('dictionary prewarm does not show OSD when notifications are disabled', async () => {
const osdMessages: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: async () => undefined,
ensureFrequencyDictionaryLookup: async () => undefined,
shouldShowOsdNotification: () => false,
showMpvOsd: (message) => {
osdMessages.push(message);
},
});
await prewarm({ showLoadingOsd: true });
assert.deepEqual(osdMessages, []);
});

View File

@@ -66,8 +66,88 @@ export function createCreateMecabTokenizerAndCheckMainHandler<TMecab>(deps: {
export function createPrewarmSubtitleDictionariesMainHandler(deps: { export function createPrewarmSubtitleDictionariesMainHandler(deps: {
ensureJlptDictionaryLookup: () => Promise<void>; ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>; ensureFrequencyDictionaryLookup: () => Promise<void>;
showMpvOsd?: (message: string) => void;
shouldShowOsdNotification?: () => boolean;
setInterval?: (callback: () => void, delayMs: number) => unknown;
clearInterval?: (timer: unknown) => void;
}) { }) {
return async (): Promise<void> => { let prewarmed = false;
let prewarmPromise: Promise<void> | null = null;
let loadingOsdDepth = 0;
let loadingOsdFrame = 0;
let loadingOsdTimer: unknown = null;
const showMpvOsd = deps.showMpvOsd;
const shouldShowOsdNotification = deps.shouldShowOsdNotification ?? (() => false);
const setIntervalHandler =
deps.setInterval ??
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
const clearIntervalHandler =
deps.clearInterval ??
((timer: unknown): void => clearInterval(timer as ReturnType<typeof setInterval>));
const spinnerFrames = ['|', '/', '-', '\\'];
const beginLoadingOsd = (): boolean => {
if (!showMpvOsd || !shouldShowOsdNotification()) {
return false;
}
loadingOsdDepth += 1;
if (loadingOsdDepth > 1) {
return true;
}
loadingOsdFrame = 0;
showMpvOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
loadingOsdFrame += 1;
loadingOsdTimer = setIntervalHandler(() => {
if (!showMpvOsd) {
return;
}
showMpvOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`);
loadingOsdFrame += 1;
}, 180);
return true;
};
const endLoadingOsd = (): void => {
if (!showMpvOsd || !shouldShowOsdNotification()) {
return;
}
loadingOsdDepth = Math.max(0, loadingOsdDepth - 1);
if (loadingOsdDepth > 0) {
return;
}
if (loadingOsdTimer) {
clearIntervalHandler(loadingOsdTimer);
loadingOsdTimer = null;
}
showMpvOsd('Subtitle annotations loaded');
};
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {
if (prewarmed) {
return;
}
const shouldTrackLoadingOsd = options?.showLoadingOsd === true;
const loadingOsdStarted = shouldTrackLoadingOsd ? beginLoadingOsd() : false;
if (!prewarmPromise) {
prewarmPromise = (async () => {
try {
await Promise.all([deps.ensureJlptDictionaryLookup(), deps.ensureFrequencyDictionaryLookup()]); await Promise.all([deps.ensureJlptDictionaryLookup(), deps.ensureFrequencyDictionaryLookup()]);
prewarmed = true;
} finally {
prewarmPromise = null;
}
})();
}
try {
await prewarmPromise;
} finally {
if (loadingOsdStarted) {
endLoadingOsd();
}
}
}; };
} }