fix(subtitle-ws): send tokenized payloads to texthooker

This commit is contained in:
2026-02-19 17:21:26 -08:00
parent d5d71816ac
commit 7795cc3d69
5 changed files with 376 additions and 179 deletions

View File

@@ -158,6 +158,18 @@ import {
} from './main/runtime/jellyfin-remote-session-lifecycle';
import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler';
import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks';
import { createCliCommandContext } from './main/runtime/cli-command-context';
import {
createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler,
createHandleMpvSubtitleTimingHandler,
} from './main/runtime/mpv-client-event-bindings';
import { createMpvClientRuntimeServiceFactory } from './main/runtime/mpv-client-runtime-service';
import { createUpdateMpvSubtitleRenderMetricsHandler } from './main/runtime/mpv-subtitle-render-metrics';
import {
createLaunchBackgroundWarmupTaskHandler,
createStartBackgroundWarmupsHandler,
} from './main/runtime/startup-warmups';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
@@ -460,13 +472,18 @@ const subsyncRuntime = createMainSubsyncRuntime({
let appTray: Tray | null = null;
const subtitleProcessingController = createSubtitleProcessingController({
tokenizeSubtitle: async (text: string) => {
if (getOverlayWindows().length === 0) {
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
return null;
}
return await tokenizeSubtitle(text);
},
emitSubtitle: (payload) => {
broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
},
logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`);
@@ -1871,12 +1888,12 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
logInfo: (message) => logger.info(message),
})(args);
handleCliCommandRuntimeServiceWithContext(args, source, {
const cliContext = createCliCommandContext({
getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath;
},
getClient: () => appState.mpvClient,
getMpvClient: () => appState.mpvClient,
showOsd: (text: string) => showMpvOsd(text),
texthookerService,
getTexthookerPort: () => appState.texthookerPort,
@@ -1884,11 +1901,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
appState.texthookerPort = port;
},
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => {
void shell.openExternal(url).catch((error) => {
logger.error(`Failed to open browser for texthooker URL: ${url}`, error);
});
},
openExternal: (url: string) => shell.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
isOverlayInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlay: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
@@ -1920,16 +1935,11 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
log: (message: string) => {
logger.info(message);
},
warn: (message: string) => {
logger.warn(message);
},
error: (message: string, err: unknown) => {
logger.error(message, err);
},
logInfo: (message: string) => logger.info(message),
logWarn: (message: string) => logger.warn(message),
logError: (message: string, err: unknown) => logger.error(message, err),
});
handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
}
function handleInitialArgs(): void {
@@ -1946,104 +1956,119 @@ function handleInitialArgs(): void {
}
function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
mpvClient.on('connection-change', ({ connected }) => {
if (connected) return;
void reportJellyfinRemoteStopped();
if (!appState.initialArgs?.jellyfinPlay) return;
if (appState.overlayRuntimeInitialized) return;
if (!jellyfinPlayQuitOnDisconnectArmed) return;
setTimeout(() => {
if (appState.mpvClient?.connected) return;
app.quit();
}, 500);
});
mpvClient.on('subtitle-change', ({ text }) => {
appState.currentSubText = text;
subtitleWsService.broadcast(text);
subtitleProcessingController.onSubtitleChange(text);
});
mpvClient.on('subtitle-ass-change', ({ text }) => {
appState.currentSubAssText = text;
broadcastToOverlayWindows('subtitle-ass:set', text);
});
mpvClient.on('secondary-subtitle-change', ({ text }) => {
broadcastToOverlayWindows('secondary-subtitle:set', text);
});
mpvClient.on('subtitle-timing', ({ text, start, end }) => {
if (!text.trim()) {
return;
}
appState.immersionTracker?.recordSubtitleLine(text, start, end);
if (!appState.subtitleTimingTracker) {
return;
}
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
void maybeRunAnilistPostWatchUpdate().catch((error) => {
logger.error('AniList post-watch update failed unexpectedly', error);
});
});
mpvClient.on('media-path-change', ({ path }) => {
mediaRuntime.updateCurrentMediaPath(path);
if (!path) {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
}
const mediaKey = getCurrentAnilistMediaKey();
resetAnilistMediaTracking(mediaKey);
if (mediaKey) {
void maybeProbeAnilistDuration(mediaKey);
void ensureAnilistMediaGuess(mediaKey);
}
immersionMediaRuntime.syncFromCurrentMediaState();
},
hasInitialJellyfinPlayArg: () => Boolean(appState.initialArgs?.jellyfinPlay),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
isQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
scheduleQuitCheck: (callback) => {
setTimeout(callback, 500);
},
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
quitApp: () => app.quit(),
});
mpvClient.on('media-title-change', ({ title }) => {
mediaRuntime.updateCurrentMediaTitle(title);
anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = null;
appState.immersionTracker?.handleMediaTitleUpdate(title);
immersionMediaRuntime.syncFromCurrentMediaState();
});
mpvClient.on('time-pos-change', ({ time }) => {
appState.immersionTracker?.recordPlaybackPosition(time);
void reportJellyfinRemoteProgress(false);
});
mpvClient.on('pause-change', ({ paused }) => {
appState.immersionTracker?.recordPauseState(paused);
void reportJellyfinRemoteProgress(true);
});
mpvClient.on('subtitle-metrics-change', ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch);
});
mpvClient.on('secondary-subtitle-visibility', ({ visible }) => {
appState.previousSecondarySubVisibility = visible;
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) => {
appState.immersionTracker?.recordSubtitleLine(text, start, end);
},
hasSubtitleTimingTracker: () => Boolean(appState.subtitleTimingTracker),
recordSubtitleTiming: (text, start, end) => {
appState.subtitleTimingTracker?.recordSubtitle(text, start, end);
},
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
logError: (message, error) => logger.error(message, error),
});
createBindMpvClientEventHandlers({
onConnectionChange: (payload) => {
handleMpvConnectionChange(payload);
},
onSubtitleChange: ({ text }) => {
appState.currentSubText = text;
broadcastToOverlayWindows('subtitle:set', { text, tokens: null });
subtitleProcessingController.onSubtitleChange(text);
},
onSubtitleAssChange: ({ text }) => {
appState.currentSubAssText = text;
broadcastToOverlayWindows('subtitle-ass:set', text);
},
onSecondarySubtitleChange: ({ text }) => {
broadcastToOverlayWindows('secondary-subtitle:set', text);
},
onSubtitleTiming: (payload) => {
handleMpvSubtitleTiming(payload);
},
onMediaPathChange: ({ path }) => {
mediaRuntime.updateCurrentMediaPath(path);
if (!path) {
void reportJellyfinRemoteStopped();
}
const mediaKey = getCurrentAnilistMediaKey();
resetAnilistMediaTracking(mediaKey);
if (mediaKey) {
void maybeProbeAnilistDuration(mediaKey);
void ensureAnilistMediaGuess(mediaKey);
}
immersionMediaRuntime.syncFromCurrentMediaState();
},
onMediaTitleChange: ({ title }) => {
mediaRuntime.updateCurrentMediaTitle(title);
anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = null;
appState.immersionTracker?.handleMediaTitleUpdate(title);
immersionMediaRuntime.syncFromCurrentMediaState();
},
onTimePosChange: ({ time }) => {
appState.immersionTracker?.recordPlaybackPosition(time);
void reportJellyfinRemoteProgress(false);
},
onPauseChange: ({ paused }) => {
appState.immersionTracker?.recordPauseState(paused);
void reportJellyfinRemoteProgress(true);
},
onSubtitleMetricsChange: ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
},
onSecondarySubtitleVisibility: ({ visible }) => {
appState.previousSecondarySubVisibility = visible;
},
})(mpvClient);
}
function createMpvClientRuntimeService(): MpvIpcClient {
const mpvClient = new MpvIpcClient(appState.mpvSocketPath, {
getResolvedConfig: () => getResolvedConfig(),
autoStartOverlay: appState.autoStartOverlay,
setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getReconnectTimer: () => appState.reconnectTimer,
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
return createMpvClientRuntimeServiceFactory({
createClient: MpvIpcClient,
socketPath: appState.mpvSocketPath,
options: {
getResolvedConfig: () => getResolvedConfig(),
autoStartOverlay: appState.autoStartOverlay,
setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getReconnectTimer: () => appState.reconnectTimer,
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
},
});
bindMpvClientEventHandlers(mpvClient);
mpvClient.connect();
return mpvClient;
bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
})();
}
const updateMpvSubtitleRenderMetricsRuntime = createUpdateMpvSubtitleRenderMetricsHandler({
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,
setCurrentMetrics: (metrics) => {
appState.mpvSubtitleRenderMetrics = metrics;
},
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: (metrics) => {
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics);
},
});
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
const { next, changed } = applyMpvSubtitleRenderMetricsPatch(
appState.mpvSubtitleRenderMetrics,
patch,
);
if (!changed) return;
appState.mpvSubtitleRenderMetrics = next;
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', appState.mpvSubtitleRenderMetrics);
updateMpvSubtitleRenderMetricsRuntime(patch);
}
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
@@ -2101,41 +2126,25 @@ async function prewarmSubtitleDictionaries(): Promise<void> {
]);
}
function launchBackgroundWarmupTask(label: string, task: () => Promise<void>): void {
const startedAtMs = Date.now();
void task()
.then(() => {
logger.debug(`[startup-warmup] ${label} completed in ${Date.now() - startedAtMs}ms`);
})
.catch((error) => {
logger.warn(`[startup-warmup] ${label} failed: ${(error as Error).message}`);
});
}
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler({
now: () => Date.now(),
logDebug: (message) => logger.debug(message),
logWarn: (message) => logger.warn(message),
});
function startBackgroundWarmups(): void {
if (backgroundWarmupsStarted) {
return;
}
if (appState.texthookerOnlyMode) {
return;
}
backgroundWarmupsStarted = true;
launchBackgroundWarmupTask('mecab', async () => {
await createMecabTokenizerAndCheck();
});
launchBackgroundWarmupTask('yomitan-extension', async () => {
await ensureYomitanExtensionLoaded();
});
launchBackgroundWarmupTask('subtitle-dictionaries', async () => {
await prewarmSubtitleDictionaries();
});
if (getResolvedConfig().jellyfin.remoteControlAutoConnect) {
launchBackgroundWarmupTask('jellyfin-remote-session', async () => {
await startJellyfinRemoteSession();
});
}
}
const startBackgroundWarmups = createStartBackgroundWarmupsHandler({
getStarted: () => backgroundWarmupsStarted,
setStarted: (started) => {
backgroundWarmupsStarted = started;
},
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
});
function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
overlayManager.setOverlayWindowBounds('visible', geometry);