From bf333c7c08e1bb42aaed80d41e2c26643b94be1c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 28 Feb 2026 15:38:03 -0800 Subject: [PATCH] fix(osd): show subtitle-annotation loading status during tokenization --- src/main.ts | 6 ++ .../runtime/composers/mpv-runtime-composer.ts | 23 +++- .../subtitle-tokenization-main-deps.test.ts | 101 ++++++++++++++++++ .../subtitle-tokenization-main-deps.ts | 84 ++++++++++++++- 4 files changed, 207 insertions(+), 7 deletions(-) diff --git a/src/main.ts b/src/main.ts index 7c8109a..3671421 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2322,6 +2322,11 @@ const { ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), ensureFrequencyDictionaryLookup: () => frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), + showMpvOsd: (message: string) => showMpvOsd(message), + shouldShowOsdNotification: () => { + const type = getResolvedConfig().ankiConnect.behavior.notificationType; + return type === 'osd' || type === 'both'; + }, }, }, warmups: { @@ -2366,6 +2371,7 @@ const { ); }, startJellyfinRemoteSession: () => startJellyfinRemoteSession(), + logDebug: (message) => logger.debug(message), }, }, }); diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts index d825de7..898c2d8 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -87,6 +87,7 @@ export type MpvRuntimeComposerResult< tokenizeSubtitle: (text: string) => Promise; createMecabTokenizerAndCheck: () => Promise; prewarmSubtitleDictionaries: () => Promise; + startTokenizationWarmups: () => Promise; launchBackgroundWarmupTask: ReturnType; startBackgroundWarmups: ReturnType; }>; @@ -132,12 +133,23 @@ export function composeMpvRuntimeHandlers< const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler( options.tokenizer.prewarmSubtitleDictionariesMainDeps, ); - const tokenizeSubtitle = async (text: string): Promise => { - await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded(); - if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) { - await createMecabTokenizerAndCheck().catch(() => {}); + let tokenizationWarmupInFlight: Promise | null = null; + const startTokenizationWarmups = (): Promise => { + if (!tokenizationWarmupInFlight) { + tokenizationWarmupInFlight = (async () => { + await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded(); + if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) { + await createMecabTokenizerAndCheck().catch(() => {}); + } + await prewarmSubtitleDictionaries({ showLoadingOsd: true }); + })().finally(() => { + tokenizationWarmupInFlight = null; + }); } - await prewarmSubtitleDictionaries(); + return tokenizationWarmupInFlight; + }; + const tokenizeSubtitle = async (text: string): Promise => { + await startTokenizationWarmups(); return options.tokenizer.tokenizeSubtitle( text, options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()), @@ -165,6 +177,7 @@ export function composeMpvRuntimeHandlers< tokenizeSubtitle, createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + startTokenizationWarmups, launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task), startBackgroundWarmups: () => startBackgroundWarmups(), }; diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts index 53e2472..0efe713 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.test.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -6,6 +6,17 @@ import { createPrewarmSubtitleDictionariesMainHandler, } from './subtitle-tokenization-main-deps'; +function createDeferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + test('tokenizer deps builder records known-word lookups and maps readers', () => { const calls: string[] = []; const deps = createBuildTokenizerDepsMainHandler({ @@ -77,3 +88,93 @@ test('dictionary prewarm runs both dictionary loaders', async () => { await prewarm(); 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, []); +}); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index b9381bc..8ef9fa6 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -66,8 +66,88 @@ export function createCreateMecabTokenizerAndCheckMainHandler(deps: { export function createPrewarmSubtitleDictionariesMainHandler(deps: { ensureJlptDictionaryLookup: () => Promise; ensureFrequencyDictionaryLookup: () => Promise; + showMpvOsd?: (message: string) => void; + shouldShowOsdNotification?: () => boolean; + setInterval?: (callback: () => void, delayMs: number) => unknown; + clearInterval?: (timer: unknown) => void; }) { - return async (): Promise => { - await Promise.all([deps.ensureJlptDictionaryLookup(), deps.ensureFrequencyDictionaryLookup()]); + let prewarmed = false; + let prewarmPromise: Promise | 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)); + 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 => { + 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()]); + prewarmed = true; + } finally { + prewarmPromise = null; + } + })(); + } + try { + await prewarmPromise; + } finally { + if (loadingOsdStarted) { + endLoadingOsd(); + } + } }; }