mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18: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
|
state.auto_play_ready_osd_timer = nil
|
||||||
end
|
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_timeout()
|
||||||
clear_auto_play_ready_osd_timer()
|
clear_auto_play_ready_osd_timer()
|
||||||
state.auto_play_ready_gate_armed = false
|
state.auto_play_ready_gate_armed = false
|
||||||
|
if was_armed and should_resume then
|
||||||
|
mp.set_property_native("pause", false)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function release_auto_play_ready_gate(reason)
|
local function release_auto_play_ready_gate(reason)
|
||||||
if not state.auto_play_ready_gate_armed then
|
if not state.auto_play_ready_gate_armed then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
disarm_auto_play_ready_gate()
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
mp.set_property_native("pause", false)
|
mp.set_property_native("pause", false)
|
||||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
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 state.overlay_running then
|
||||||
if overrides.auto_start_trigger == true then
|
if overrides.auto_start_trigger == true then
|
||||||
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
|
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
|
return
|
||||||
end
|
end
|
||||||
subminer_log("info", "process", "Overlay already running")
|
subminer_log("info", "process", "Overlay already running")
|
||||||
|
|||||||
@@ -304,6 +304,26 @@ local function find_control_call(async_calls, flag)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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 function call_has_arg(call, target)
|
||||||
local args = (call and call.args) or {}
|
local args = (call and call.args) or {}
|
||||||
for _, value in ipairs(args) do
|
for _, value in ipairs(args) do
|
||||||
@@ -375,6 +395,16 @@ local function count_osd_message(messages, target)
|
|||||||
return count
|
return count
|
||||||
end
|
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 function fire_event(recorded, name)
|
||||||
local listeners = recorded.events[name] or {}
|
local listeners = recorded.events[name] or {}
|
||||||
for _, listener in ipairs(listeners) do
|
for _, listener in ipairs(listeners) do
|
||||||
@@ -516,12 +546,64 @@ do
|
|||||||
count_start_calls(recorded.async_calls) == 1,
|
count_start_calls(recorded.async_calls) == 1,
|
||||||
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
|
"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(
|
assert_true(
|
||||||
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
|
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
|
||||||
"duplicate auto-start events should not show Already running OSD"
|
"duplicate auto-start events should not show Already running OSD"
|
||||||
)
|
)
|
||||||
end
|
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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -572,6 +654,35 @@ do
|
|||||||
)
|
)
|
||||||
end
|
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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -297,6 +297,60 @@ test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in p
|
|||||||
assert.equal(result.tokens?.[0]?.frequencyRank, 77);
|
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 () => {
|
test('tokenizeSubtitle appends trailing kana to merged Yomitan readings when headword equals surface', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'断じて見ていない',
|
'断じて見ていない',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
|
|||||||
getYomitanGroupDebugEnabled?: () => boolean;
|
getYomitanGroupDebugEnabled?: () => boolean;
|
||||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||||
enrichTokensWithMecab?: MecabTokenEnrichmentFn;
|
enrichTokensWithMecab?: MecabTokenEnrichmentFn;
|
||||||
|
onTokenizationReady?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MecabTokenizerLike {
|
interface MecabTokenizerLike {
|
||||||
@@ -78,6 +79,7 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
getMinSentenceWordsForNPlusOne?: () => number;
|
getMinSentenceWordsForNPlusOne?: () => number;
|
||||||
getYomitanGroupDebugEnabled?: () => boolean;
|
getYomitanGroupDebugEnabled?: () => boolean;
|
||||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||||
|
onTokenizationReady?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TokenizerAnnotationOptions {
|
interface TokenizerAnnotationOptions {
|
||||||
@@ -215,6 +217,7 @@ export function createTokenizerDepsRuntime(
|
|||||||
},
|
},
|
||||||
enrichTokensWithMecab: async (tokens, mecabTokens) =>
|
enrichTokensWithMecab: async (tokens, mecabTokens) =>
|
||||||
enrichTokensWithMecabAsync(tokens, mecabTokens),
|
enrichTokensWithMecabAsync(tokens, mecabTokens),
|
||||||
|
onTokenizationReady: options.onTokenizationReady,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +480,7 @@ async function parseWithYomitanInternalParser(
|
|||||||
if (deps.getYomitanGroupDebugEnabled?.() === true) {
|
if (deps.getYomitanGroupDebugEnabled?.() === true) {
|
||||||
logSelectedYomitanGroups(text, normalizedSelectedTokens);
|
logSelectedYomitanGroups(text, normalizedSelectedTokens);
|
||||||
}
|
}
|
||||||
|
deps.onTokenizationReady?.(text);
|
||||||
|
|
||||||
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled
|
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled
|
||||||
? (async () => {
|
? (async () => {
|
||||||
|
|||||||
82
src/main.ts
82
src/main.ts
@@ -854,21 +854,30 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH
|
|||||||
let autoPlayReadySignalMediaPath: string | null = null;
|
let autoPlayReadySignalMediaPath: string | null = null;
|
||||||
let autoPlayReadySignalGeneration = 0;
|
let autoPlayReadySignalGeneration = 0;
|
||||||
|
|
||||||
function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
|
function maybeSignalPluginAutoplayReady(
|
||||||
|
payload: SubtitleData,
|
||||||
|
options?: { forceWhilePaused?: boolean },
|
||||||
|
): void {
|
||||||
if (!payload.text.trim()) {
|
if (!payload.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mediaPath = appState.currentMediaPath;
|
const mediaPath =
|
||||||
if (!mediaPath) {
|
appState.currentMediaPath?.trim() ||
|
||||||
return;
|
appState.mpvClient?.currentVideoPath?.trim() ||
|
||||||
}
|
'__unknown__';
|
||||||
if (autoPlayReadySignalMediaPath === mediaPath) {
|
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||||
|
const allowDuplicateWhilePaused =
|
||||||
|
options?.forceWhilePaused === true && appState.playbackPaused !== false;
|
||||||
|
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
autoPlayReadySignalMediaPath = mediaPath;
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
|
const signalPluginAutoplayReady = (): void => {
|
||||||
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
|
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
|
||||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||||
|
};
|
||||||
|
signalPluginAutoplayReady();
|
||||||
const isPlaybackPaused = async (client: {
|
const isPlaybackPaused = async (client: {
|
||||||
requestProperty: (property: string) => Promise<unknown>;
|
requestProperty: (property: string) => Promise<unknown>;
|
||||||
}): Promise<boolean> => {
|
}): Promise<boolean> => {
|
||||||
@@ -892,24 +901,11 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback: unpause directly in case plugin readiness handler is unavailable/outdated.
|
// Fallback: repeatedly try to release pause for a short window in case startup
|
||||||
void (async () => {
|
// gate arming and tokenization-ready signal arrive out of order.
|
||||||
const mpvClient = appState.mpvClient;
|
const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3;
|
||||||
if (!mpvClient?.connected) {
|
const releaseRetryDelayMs = 200;
|
||||||
logger.debug('[autoplay-ready] skipped unpause fallback; mpv not connected');
|
const attemptRelease = (attempt: number): void => {
|
||||||
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 () => {
|
void (async () => {
|
||||||
if (
|
if (
|
||||||
autoPlayReadySignalMediaPath !== mediaPath ||
|
autoPlayReadySignalMediaPath !== mediaPath ||
|
||||||
@@ -918,29 +914,39 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const followupClient = appState.mpvClient;
|
const mpvClient = appState.mpvClient;
|
||||||
if (!followupClient?.connected) {
|
if (!mpvClient?.connected) {
|
||||||
|
if (attempt < maxReleaseAttempts) {
|
||||||
|
setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldUnpauseFollowup = await isPlaybackPaused(followupClient);
|
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||||
if (!shouldUnpauseFollowup) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
followupClient.send({ command: ['set_property', 'pause', false] });
|
|
||||||
})();
|
signalPluginAutoplayReady();
|
||||||
}, 500);
|
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||||
logger.debug('[autoplay-ready] issued direct mpv unpause fallback');
|
if (attempt < maxReleaseAttempts) {
|
||||||
|
setTimeout(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
};
|
||||||
|
attemptRelease(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let appTray: Tray | null = null;
|
let appTray: Tray | null = null;
|
||||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||||
tokenizeSubtitle: async (text: string) => {
|
tokenizeSubtitle: async (text: string) => {
|
||||||
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await tokenizeSubtitle(text);
|
return await tokenizeSubtitle(text);
|
||||||
},
|
},
|
||||||
emitSubtitle: (payload) => {
|
emitSubtitle: (payload) => {
|
||||||
@@ -951,7 +957,6 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
|||||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||||
});
|
});
|
||||||
maybeSignalPluginAutoplayReady(payload);
|
|
||||||
},
|
},
|
||||||
logDebug: (message) => {
|
logDebug: (message) => {
|
||||||
logger.debug(`[subtitle-processing] ${message}`);
|
logger.debug(`[subtitle-processing] ${message}`);
|
||||||
@@ -2335,9 +2340,7 @@ const {
|
|||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
},
|
},
|
||||||
updateCurrentMediaPath: (path) => {
|
updateCurrentMediaPath: (path) => {
|
||||||
if (appState.currentMediaPath !== path) {
|
|
||||||
autoPlayReadySignalMediaPath = null;
|
autoPlayReadySignalMediaPath = null;
|
||||||
}
|
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
}
|
}
|
||||||
@@ -2443,6 +2446,9 @@ const {
|
|||||||
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
|
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
|
||||||
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
|
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
|
onTokenizationReady: (text) => {
|
||||||
|
maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
createTokenizerRuntimeDeps: (deps) =>
|
createTokenizerRuntimeDeps: (deps) =>
|
||||||
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
|
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.deepEqual(tokenizeCalls, ['first', 'second']);
|
||||||
assert.equal(yomitanWarmupCalls, 1);
|
assert.equal(yomitanWarmupCalls, 1);
|
||||||
assert.equal(prewarmJlptCalls, 1);
|
assert.equal(prewarmJlptCalls, 0);
|
||||||
assert.equal(prewarmFrequencyCalls, 1);
|
assert.equal(prewarmFrequencyCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('composeMpvRuntimeHandlers does not block first tokenization on dictionary or MeCab warmup', async () => {
|
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 tokenizePromise;
|
||||||
await composed.startTokenizationWarmups();
|
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;
|
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
|
||||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
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 tokenizationWarmupInFlight: Promise<void> | null = null;
|
||||||
let tokenizationPrerequisiteWarmupInFlight: Promise<void> | null = null;
|
let tokenizationPrerequisiteWarmupInFlight: Promise<void> | null = null;
|
||||||
let tokenizationPrerequisiteWarmupCompleted = false;
|
let tokenizationPrerequisiteWarmupCompleted = false;
|
||||||
@@ -174,7 +180,9 @@ export function composeMpvRuntimeHandlers<
|
|||||||
) {
|
) {
|
||||||
warmupTasks.push(createMecabTokenizerAndCheck().catch(() => {}));
|
warmupTasks.push(createMecabTokenizerAndCheck().catch(() => {}));
|
||||||
}
|
}
|
||||||
warmupTasks.push(prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {}));
|
if (shouldWarmupAnnotationDictionaries()) {
|
||||||
|
warmupTasks.push(prewarmSubtitleDictionaries().catch(() => {}));
|
||||||
|
}
|
||||||
await Promise.all(warmupTasks);
|
await Promise.all(warmupTasks);
|
||||||
tokenizationWarmupCompleted = true;
|
tokenizationWarmupCompleted = true;
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
@@ -186,9 +194,19 @@ export function composeMpvRuntimeHandlers<
|
|||||||
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
||||||
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
|
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
|
||||||
await ensureTokenizationPrerequisites();
|
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(
|
return options.tokenizer.tokenizeSubtitle(
|
||||||
text,
|
text,
|
||||||
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
|
options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a
|
|||||||
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
|
assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dictionary prewarm does not show OSD when notifications are disabled', async () => {
|
test(
|
||||||
|
'dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled',
|
||||||
|
async () => {
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
|
|
||||||
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
|
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
|
||||||
@@ -181,8 +183,12 @@ test('dictionary prewarm does not show OSD when notifications are disabled', asy
|
|||||||
|
|
||||||
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 () => {
|
test('dictionary prewarm clears loading OSD timer even if notifications are disabled before completion', async () => {
|
||||||
const clearedTimers: unknown[] = [];
|
const clearedTimers: unknown[] = [];
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
|||||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||||
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
|
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
|
||||||
getMecabTokenizer: () => deps.getMecabTokenizer(),
|
getMecabTokenizer: () => deps.getMecabTokenizer(),
|
||||||
|
onTokenizationReady: (text: string) => deps.onTokenizationReady?.(text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +82,6 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
let loadingOsdFrame = 0;
|
let loadingOsdFrame = 0;
|
||||||
let loadingOsdTimer: unknown = null;
|
let loadingOsdTimer: unknown = null;
|
||||||
const showMpvOsd = deps.showMpvOsd;
|
const showMpvOsd = deps.showMpvOsd;
|
||||||
const shouldShowOsdNotification = deps.shouldShowOsdNotification ?? (() => false);
|
|
||||||
const setIntervalHandler =
|
const setIntervalHandler =
|
||||||
deps.setInterval ??
|
deps.setInterval ??
|
||||||
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
||||||
@@ -91,7 +91,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
const spinnerFrames = ['|', '/', '-', '\\'];
|
const spinnerFrames = ['|', '/', '-', '\\'];
|
||||||
|
|
||||||
const beginLoadingOsd = (): boolean => {
|
const beginLoadingOsd = (): boolean => {
|
||||||
if (!showMpvOsd || !shouldShowOsdNotification()) {
|
if (!showMpvOsd) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
loadingOsdDepth += 1;
|
loadingOsdDepth += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user