mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
fix: unblock autoplay on tokenization-ready and defer annotation loading
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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<unknown[]>();
|
||||
const mecabDeferred = createDeferred<null>();
|
||||
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(
|
||||
'断じて見ていない',
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
|
||||
getYomitanGroupDebugEnabled?: () => boolean;
|
||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||
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<Map<string, number>> = options.frequencyEnabled
|
||||
? (async () => {
|
||||
|
||||
106
src/main.ts
106
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<unknown>;
|
||||
}): Promise<boolean> => {
|
||||
@@ -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<typeof createTokenizerDepsRuntime>[0]),
|
||||
|
||||
@@ -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<void>((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<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Loading subtitle annotations |',
|
||||
'Subtitle annotations loaded',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
let tokenizationPrerequisiteWarmupInFlight: Promise<void> | 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<TTokenizedSubtitle> => {
|
||||
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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user