From e744fab067460623c5605d5b69a946c81bddaa71 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 2 Mar 2026 02:43:09 -0800 Subject: [PATCH] fix: unblock autoplay on tokenization-ready and defer annotation loading --- plugin/subminer/process.lua | 26 ++- scripts/test-plugin-start-gate.lua | 111 +++++++++++++ src/core/services/tokenizer.test.ts | 54 +++++++ src/core/services/tokenizer.ts | 4 + src/main.ts | 106 ++++++------ .../composers/mpv-runtime-composer.test.ts | 152 +++++++++++++++++- .../runtime/composers/mpv-runtime-composer.ts | 22 ++- .../subtitle-tokenization-main-deps.test.ts | 32 ++-- .../subtitle-tokenization-main-deps.ts | 4 +- 9 files changed, 440 insertions(+), 71 deletions(-) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 92ca7c3..6ae0b66 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -80,17 +80,22 @@ function M.create(ctx) state.auto_play_ready_osd_timer = nil end - local function disarm_auto_play_ready_gate() + local function disarm_auto_play_ready_gate(options) + local should_resume = options == nil or options.resume_playback ~= false + local was_armed = state.auto_play_ready_gate_armed clear_auto_play_ready_timeout() clear_auto_play_ready_osd_timer() state.auto_play_ready_gate_armed = false + if was_armed and should_resume then + mp.set_property_native("pause", false) + end end local function release_auto_play_ready_gate(reason) if not state.auto_play_ready_gate_armed then return end - disarm_auto_play_ready_gate() + disarm_auto_play_ready_gate({ resume_playback = false }) mp.set_property_native("pause", false) show_osd(AUTO_PLAY_READY_READY_OSD) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) @@ -270,6 +275,23 @@ function M.create(ctx) if state.overlay_running then if overrides.auto_start_trigger == true then subminer_log("debug", "process", "Auto-start ignored because overlay is already running") + local socket_path = overrides.socket_path or opts.socket_path + local should_pause_until_ready = ( + resolve_visible_overlay_startup() + and resolve_pause_until_ready() + and has_matching_mpv_ipc_socket(socket_path) + ) + if should_pause_until_ready then + arm_auto_play_ready_gate() + else + disarm_auto_play_ready_gate() + end + local visibility_action = resolve_visible_overlay_startup() + and "show-visible-overlay" + or "hide-visible-overlay" + run_control_command_async(visibility_action, { + log_level = overrides.log_level, + }) return end subminer_log("info", "process", "Overlay already running") diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 4a9bb5f..9d80731 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -304,6 +304,26 @@ local function find_control_call(async_calls, flag) return nil end +local function count_control_calls(async_calls, flag) + local count = 0 + for _, call in ipairs(async_calls) do + local args = call.args or {} + local has_flag = false + local has_start = false + for _, value in ipairs(args) do + if value == flag then + has_flag = true + elseif value == "--start" then + has_start = true + end + end + if has_flag and not has_start then + count = count + 1 + end + end + return count +end + local function call_has_arg(call, target) local args = (call and call.args) or {} for _, value in ipairs(args) do @@ -375,6 +395,16 @@ local function count_osd_message(messages, target) return count end +local function count_property_set(property_sets, name, value) + local count = 0 + for _, call in ipairs(property_sets) do + if call.name == name and call.value == value then + count = count + 1 + end + end + return count +end + local function fire_event(recorded, name) local listeners = recorded.events[name] or {} for _, listener in ipairs(listeners) do @@ -516,12 +546,64 @@ do count_start_calls(recorded.async_calls) == 1, "duplicate file-loaded events should not issue duplicate --start commands while overlay is already running" ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "duplicate auto-start should re-assert visible overlay state when overlay is already running" + ) assert_true( count_osd_message(recorded.osd, "SubMiner: Already running") == 0, "duplicate auto-start events should not show Already running OSD" ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for duplicate auto-start pause-until-ready scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered") + recorded.script_messages["subminer-autoplay-ready"]() + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_start_calls(recorded.async_calls) == 1, + "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" + ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "duplicate pause-until-ready auto-start should still re-assert visible overlay state" + ) + assert_true( + count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2, + "duplicate pause-until-ready auto-start should arm tokenization loading gate for each file" + ) + assert_true( + count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2, + "duplicate pause-until-ready auto-start should release tokenization gate for each file" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 2, + "duplicate pause-until-ready auto-start should force pause for each file" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 2, + "duplicate pause-until-ready auto-start should resume playback for each file" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -572,6 +654,35 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for pause cleanup scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + fire_event(recorded, "end-file") + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 1, + "pause cleanup scenario should force pause while waiting for tokenization" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 1, + "ending file while gate is armed should clear forced pause state" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 4dd8450..66ea5be 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -297,6 +297,60 @@ test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in p assert.equal(result.tokens?.[0]?.frequencyRank, 77); }); +test('tokenizeSubtitle can signal tokenization-ready before enrichment completes', async () => { + const frequencyDeferred = createDeferred(); + const mecabDeferred = createDeferred(); + let tokenizationReadyText: string | null = null; + + const pendingResult = tokenizeSubtitle( + '猫', + makeDeps({ + onTokenizationReady: (text) => { + tokenizationReadyText = text; + }, + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async (script: string) => { + if (script.includes('getTermFrequencies')) { + return await frequencyDeferred.promise; + } + + return [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: '猫' }]], + }, + ], + ], + }, + ]; + }, + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => { + return await mecabDeferred.promise; + }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tokenizationReadyText, '猫'); + + frequencyDeferred.resolve([]); + mecabDeferred.resolve(null); + await pendingResult; +}); + test('tokenizeSubtitle appends trailing kana to merged Yomitan readings when headword equals surface', async () => { const result = await tokenizeSubtitle( '断じて見ていない', diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index d04df15..b2a1fd4 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -51,6 +51,7 @@ export interface TokenizerServiceDeps { getYomitanGroupDebugEnabled?: () => boolean; tokenizeWithMecab: (text: string) => Promise; enrichTokensWithMecab?: MecabTokenEnrichmentFn; + onTokenizationReady?: (text: string) => void; } interface MecabTokenizerLike { @@ -78,6 +79,7 @@ export interface TokenizerDepsRuntimeOptions { getMinSentenceWordsForNPlusOne?: () => number; getYomitanGroupDebugEnabled?: () => boolean; getMecabTokenizer: () => MecabTokenizerLike | null; + onTokenizationReady?: (text: string) => void; } interface TokenizerAnnotationOptions { @@ -215,6 +217,7 @@ export function createTokenizerDepsRuntime( }, enrichTokensWithMecab: async (tokens, mecabTokens) => enrichTokensWithMecabAsync(tokens, mecabTokens), + onTokenizationReady: options.onTokenizationReady, }; } @@ -477,6 +480,7 @@ async function parseWithYomitanInternalParser( if (deps.getYomitanGroupDebugEnabled?.() === true) { logSelectedYomitanGroups(text, normalizedSelectedTokens); } + deps.onTokenizationReady?.(text); const frequencyRankPromise: Promise> = options.frequencyEnabled ? (async () => { diff --git a/src/main.ts b/src/main.ts index e08183a..35a790a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -854,21 +854,30 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH let autoPlayReadySignalMediaPath: string | null = null; let autoPlayReadySignalGeneration = 0; -function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { +function maybeSignalPluginAutoplayReady( + payload: SubtitleData, + options?: { forceWhilePaused?: boolean }, +): void { if (!payload.text.trim()) { return; } - const mediaPath = appState.currentMediaPath; - if (!mediaPath) { - return; - } - if (autoPlayReadySignalMediaPath === mediaPath) { + const mediaPath = + appState.currentMediaPath?.trim() || + appState.mpvClient?.currentVideoPath?.trim() || + '__unknown__'; + const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath; + const allowDuplicateWhilePaused = + options?.forceWhilePaused === true && appState.playbackPaused !== false; + if (duplicateMediaSignal && !allowDuplicateWhilePaused) { return; } autoPlayReadySignalMediaPath = mediaPath; const playbackGeneration = ++autoPlayReadySignalGeneration; - logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`); - sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); + const signalPluginAutoplayReady = (): void => { + logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`); + sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); + }; + signalPluginAutoplayReady(); const isPlaybackPaused = async (client: { requestProperty: (property: string) => Promise; }): Promise => { @@ -892,55 +901,52 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { return true; }; - // Fallback: unpause directly in case plugin readiness handler is unavailable/outdated. - void (async () => { - const mpvClient = appState.mpvClient; - if (!mpvClient?.connected) { - logger.debug('[autoplay-ready] skipped unpause fallback; mpv not connected'); - return; - } + // Fallback: repeatedly try to release pause for a short window in case startup + // gate arming and tokenization-ready signal arrive out of order. + const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3; + const releaseRetryDelayMs = 200; + const attemptRelease = (attempt: number): void => { + void (async () => { + if ( + autoPlayReadySignalMediaPath !== mediaPath || + playbackGeneration !== autoPlayReadySignalGeneration + ) { + return; + } - const shouldUnpause = await isPlaybackPaused(mpvClient); - logger.debug(`[autoplay-ready] mpv paused before fallback for ${mediaPath}: ${shouldUnpause}`); - - if (!shouldUnpause) { - logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed'); - return; - } - - mpvClient.send({ command: ['set_property', 'pause', false] }); - setTimeout(() => { - void (async () => { - if ( - autoPlayReadySignalMediaPath !== mediaPath || - playbackGeneration !== autoPlayReadySignalGeneration - ) { - return; + const mpvClient = appState.mpvClient; + if (!mpvClient?.connected) { + if (attempt < maxReleaseAttempts) { + setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs); } + return; + } - const followupClient = appState.mpvClient; - if (!followupClient?.connected) { - return; + const shouldUnpause = await isPlaybackPaused(mpvClient); + logger.debug( + `[autoplay-ready] mpv paused before fallback attempt ${attempt} for ${mediaPath}: ${shouldUnpause}`, + ); + if (!shouldUnpause) { + if (attempt === 0) { + logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed'); } + return; + } - const shouldUnpauseFollowup = await isPlaybackPaused(followupClient); - if (!shouldUnpauseFollowup) { - return; - } - followupClient.send({ command: ['set_property', 'pause', false] }); - })(); - }, 500); - logger.debug('[autoplay-ready] issued direct mpv unpause fallback'); - })(); + signalPluginAutoplayReady(); + mpvClient.send({ command: ['set_property', 'pause', false] }); + if (attempt < maxReleaseAttempts) { + setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs); + } + })(); + }; + attemptRelease(0); } let appTray: Tray | null = null; const buildSubtitleProcessingControllerMainDepsHandler = createBuildSubtitleProcessingControllerMainDepsHandler({ tokenizeSubtitle: async (text: string) => { - if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) { - return null; - } return await tokenizeSubtitle(text); }, emitSubtitle: (payload) => { @@ -951,7 +957,6 @@ const buildSubtitleProcessingControllerMainDepsHandler = topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, }); - maybeSignalPluginAutoplayReady(payload); }, logDebug: (message) => { logger.debug(`[subtitle-processing] ${message}`); @@ -2335,9 +2340,7 @@ const { ensureImmersionTrackerStarted(); }, updateCurrentMediaPath: (path) => { - if (appState.currentMediaPath !== path) { - autoPlayReadySignalMediaPath = null; - } + autoPlayReadySignalMediaPath = null; if (path) { ensureImmersionTrackerStarted(); } @@ -2443,6 +2446,9 @@ const { getFrequencyRank: (text) => appState.frequencyRankLookup(text), getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, getMecabTokenizer: () => appState.mecabTokenizer, + onTokenizationReady: (text) => { + maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true }); + }, }, createTokenizerRuntimeDeps: (deps) => createTokenizerDepsRuntime(deps as Parameters[0]), diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index ee94d0e..4f6e905 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -521,8 +521,8 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential assert.deepEqual(tokenizeCalls, ['first', 'second']); assert.equal(yomitanWarmupCalls, 1); - assert.equal(prewarmJlptCalls, 1); - assert.equal(prewarmFrequencyCalls, 1); + assert.equal(prewarmJlptCalls, 0); + assert.equal(prewarmFrequencyCalls, 0); }); test('composeMpvRuntimeHandlers does not block first tokenization on dictionary or MeCab warmup', async () => { @@ -658,3 +658,151 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary await tokenizePromise; await composed.startTokenizationWarmups(); }); + +test( + 'composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-ready when dictionary warmup is still pending', + async () => { + const jlptDeferred = createDeferred(); + const frequencyDeferred = createDeferred(); + const osdMessages: string[] = []; + + const composed = composeMpvRuntimeHandlers< + { connect: () => void; on: () => void }, + { onTokenizationReady?: (text: string) => void }, + { text: string } + >({ + bindMpvMainEventHandlersMainDeps: { + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: null, + immersionTracker: null, + subtitleTimingTracker: null, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: null, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + refreshDiscordPresence: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: class { + connect(): void {} + on(): void {} + }, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({ auto_start_overlay: false }), + isAutoStartOverlayEnabled: () => false, + setOverlayVisible: () => {}, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => BASE_METRICS, + setCurrentMetrics: () => {}, + applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }), + broadcastMetrics: () => {}, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: () => false, + recordLookup: () => {}, + getKnownWordMatchMode: () => 'headword', + getNPlusOneEnabled: () => false, + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => null, + getJlptEnabled: () => true, + getFrequencyDictionaryEnabled: () => true, + getFrequencyDictionaryMatchMode: () => 'headword', + getFrequencyRank: () => null, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => null, + }, + createTokenizerRuntimeDeps: (deps) => + deps as unknown as { onTokenizationReady?: (text: string) => void }, + tokenizeSubtitle: async (text, deps) => { + deps.onTokenizationReady?.(text); + return { text }; + }, + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => null, + setMecabTokenizer: () => {}, + createMecabTokenizer: () => ({ id: 'mecab' }), + checkAvailability: async () => {}, + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: async () => jlptDeferred.promise, + ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise, + showMpvOsd: (message) => { + osdMessages.push(message); + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => 0, + logDebug: () => {}, + logWarn: () => {}, + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => false, + setStarted: () => {}, + isTexthookerOnlyMode: () => false, + ensureYomitanExtensionLoaded: async () => undefined, + shouldWarmupMecab: () => false, + shouldWarmupYomitanExtension: () => false, + shouldWarmupSubtitleDictionaries: () => false, + shouldWarmupJellyfinRemoteSession: () => false, + shouldAutoConnectJellyfinRemote: () => false, + startJellyfinRemoteSession: async () => {}, + }, + }, + }); + + const warmupPromise = composed.startTokenizationWarmups(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(osdMessages, []); + + await composed.tokenizeSubtitle('first line'); + assert.deepEqual(osdMessages, ['Loading subtitle annotations |']); + + jlptDeferred.resolve(); + frequencyDeferred.resolve(); + await warmupPromise; + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(osdMessages, [ + 'Loading subtitle annotations |', + 'Subtitle annotations loaded', + ]); + }, +); diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts index b04a9b7..010e05b 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -141,6 +141,12 @@ export function composeMpvRuntimeHandlers< options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false; return nPlusOneEnabled || jlptEnabled || frequencyEnabled; }; + const shouldWarmupAnnotationDictionaries = (): boolean => { + const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false; + const frequencyEnabled = + options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false; + return jlptEnabled || frequencyEnabled; + }; let tokenizationWarmupInFlight: Promise | null = null; let tokenizationPrerequisiteWarmupInFlight: Promise | null = null; let tokenizationPrerequisiteWarmupCompleted = false; @@ -174,7 +180,9 @@ export function composeMpvRuntimeHandlers< ) { warmupTasks.push(createMecabTokenizerAndCheck().catch(() => {})); } - warmupTasks.push(prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {})); + if (shouldWarmupAnnotationDictionaries()) { + warmupTasks.push(prewarmSubtitleDictionaries().catch(() => {})); + } await Promise.all(warmupTasks); tokenizationWarmupCompleted = true; })().finally(() => { @@ -186,9 +194,19 @@ export function composeMpvRuntimeHandlers< const tokenizeSubtitle = async (text: string): Promise => { if (!tokenizationWarmupCompleted) void startTokenizationWarmups(); await ensureTokenizationPrerequisites(); + const tokenizerMainDeps = buildTokenizerDepsHandler(); + if (shouldWarmupAnnotationDictionaries()) { + const onTokenizationReady = tokenizerMainDeps.onTokenizationReady; + tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => { + onTokenizationReady?.(tokenizedText); + if (!tokenizationWarmupCompleted) { + void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {}); + } + }; + } return options.tokenizer.tokenizeSubtitle( text, - options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()), + options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps), ); }; diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts index 14c3a28..e6ffa26 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.test.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -167,22 +167,28 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']); }); -test('dictionary prewarm does not show OSD when notifications are disabled', async () => { - const osdMessages: string[] = []; +test( + 'dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', + async () => { + const osdMessages: string[] = []; - const prewarm = createPrewarmSubtitleDictionariesMainHandler({ - ensureJlptDictionaryLookup: async () => undefined, - ensureFrequencyDictionaryLookup: async () => undefined, - shouldShowOsdNotification: () => false, - showMpvOsd: (message) => { - osdMessages.push(message); - }, - }); + const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + ensureJlptDictionaryLookup: async () => undefined, + ensureFrequencyDictionaryLookup: async () => undefined, + shouldShowOsdNotification: () => false, + showMpvOsd: (message) => { + osdMessages.push(message); + }, + }); - await prewarm({ showLoadingOsd: true }); + await prewarm({ showLoadingOsd: true }); - assert.deepEqual(osdMessages, []); -}); + assert.deepEqual(osdMessages, [ + 'Loading subtitle annotations |', + 'Subtitle annotations loaded', + ]); + }, +); test('dictionary prewarm clears loading OSD timer even if notifications are disabled before completion', async () => { const clearedTimers: unknown[] = []; diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index d7699da..1bc6566 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -48,6 +48,7 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { getFrequencyRank: (text: string) => deps.getFrequencyRank(text), getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(), getMecabTokenizer: () => deps.getMecabTokenizer(), + onTokenizationReady: (text: string) => deps.onTokenizationReady?.(text), }); } @@ -81,7 +82,6 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: { 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)); @@ -91,7 +91,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: { const spinnerFrames = ['|', '/', '-', '\\']; const beginLoadingOsd = (): boolean => { - if (!showMpvOsd || !shouldShowOsdNotification()) { + if (!showMpvOsd) { return false; } loadingOsdDepth += 1;