mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(subtitle-ws): send tokenized payloads to texthooker
This commit is contained in:
@@ -7,7 +7,7 @@ function flushMicrotasks(): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
test('subtitle processing emits plain subtitle immediately before tokenized payload', async () => {
|
test('subtitle processing emits tokenized payload when tokenization succeeds', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
@@ -15,13 +15,11 @@ test('subtitle processing emits plain subtitle immediately before tokenized payl
|
|||||||
});
|
});
|
||||||
|
|
||||||
controller.onSubtitleChange('字幕');
|
controller.onSubtitleChange('字幕');
|
||||||
assert.deepEqual(emitted[0], { text: '字幕', tokens: null });
|
|
||||||
|
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
assert.deepEqual(emitted[1], { text: '字幕', tokens: [] });
|
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing drops stale tokenization and delivers latest subtitle only', async () => {
|
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
|
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
@@ -43,14 +41,10 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.deepEqual(emitted, [
|
assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]);
|
||||||
{ text: 'first', tokens: null },
|
|
||||||
{ text: 'second', tokens: null },
|
|
||||||
{ text: 'second', tokens: [] },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing skips duplicate plain subtitle emission', async () => {
|
test('subtitle processing skips duplicate subtitle emission', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
let tokenizeCalls = 0;
|
let tokenizeCalls = 0;
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
@@ -66,7 +60,19 @@ test('subtitle processing skips duplicate plain subtitle emission', async () =>
|
|||||||
controller.onSubtitleChange('same');
|
controller.onSubtitleChange('same');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
const plainEmits = emitted.filter((entry) => entry.tokens === null);
|
assert.equal(emitted.length, 1);
|
||||||
assert.equal(plainEmits.length, 1);
|
|
||||||
assert.equal(tokenizeCalls, 1);
|
assert.equal(tokenizeCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle processing falls back to plain subtitle when tokenization returns null', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('fallback');
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,19 +15,11 @@ export function createSubtitleProcessingController(
|
|||||||
deps: SubtitleProcessingControllerDeps,
|
deps: SubtitleProcessingControllerDeps,
|
||||||
): SubtitleProcessingController {
|
): SubtitleProcessingController {
|
||||||
let latestText = '';
|
let latestText = '';
|
||||||
let lastPlainText = '';
|
let lastEmittedText = '';
|
||||||
let processing = false;
|
let processing = false;
|
||||||
let staleDropCount = 0;
|
let staleDropCount = 0;
|
||||||
const now = deps.now ?? (() => Date.now());
|
const now = deps.now ?? (() => Date.now());
|
||||||
|
|
||||||
const emitPlainSubtitle = (text: string): void => {
|
|
||||||
if (text === lastPlainText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastPlainText = text;
|
|
||||||
deps.emitSubtitle({ text, tokens: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const processLatest = (): void => {
|
const processLatest = (): void => {
|
||||||
if (processing) {
|
if (processing) {
|
||||||
return;
|
return;
|
||||||
@@ -38,14 +30,20 @@ export function createSubtitleProcessingController(
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
const text = latestText;
|
const text = latestText;
|
||||||
|
const startedAtMs = now();
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
|
deps.emitSubtitle({ text, tokens: null });
|
||||||
|
lastEmittedText = text;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAtMs = now();
|
let output: SubtitleData = { text, tokens: null };
|
||||||
let tokenized: SubtitleData | null = null;
|
|
||||||
try {
|
try {
|
||||||
tokenized = await deps.tokenizeSubtitle(text);
|
const tokenized = await deps.tokenizeSubtitle(text);
|
||||||
|
if (tokenized) {
|
||||||
|
output = tokenized;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -58,12 +56,11 @@ export function createSubtitleProcessingController(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenized) {
|
deps.emitSubtitle(output);
|
||||||
deps.emitSubtitle(tokenized);
|
lastEmittedText = text;
|
||||||
deps.logDebug?.(
|
deps.logDebug?.(
|
||||||
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -72,7 +69,7 @@ export function createSubtitleProcessingController(
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
processing = false;
|
processing = false;
|
||||||
if (latestText !== lastPlainText) {
|
if (latestText !== lastEmittedText) {
|
||||||
processLatest();
|
processLatest();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,13 +80,7 @@ export function createSubtitleProcessingController(
|
|||||||
if (text === latestText) {
|
if (text === latestText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const plainStartedAtMs = now();
|
|
||||||
latestText = text;
|
latestText = text;
|
||||||
emitPlainSubtitle(text);
|
|
||||||
deps.logDebug?.(`Subtitle plain emit completed in ${now() - plainStartedAtMs}ms`);
|
|
||||||
if (!text.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
processLatest();
|
processLatest();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
89
src/core/services/subtitle-ws.test.ts
Normal file
89
src/core/services/subtitle-ws.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { serializeSubtitleMarkup, serializeSubtitleWebsocketMessage } from './subtitle-ws';
|
||||||
|
import { PartOfSpeech, type SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
const frequencyOptions = {
|
||||||
|
enabled: true,
|
||||||
|
topX: 1000,
|
||||||
|
mode: 'banded' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
|
||||||
|
const payload: SubtitleData = {
|
||||||
|
text: 'a < b\nx & y',
|
||||||
|
tokens: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(serializeSubtitleMarkup(payload, frequencyOptions), 'a < b<br>x & y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializeSubtitleMarkup includes known, n+1, jlpt, and frequency classes', () => {
|
||||||
|
const payload: SubtitleData = {
|
||||||
|
text: 'ignored',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
surface: '既知',
|
||||||
|
reading: '',
|
||||||
|
headword: '',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: true,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: '新語',
|
||||||
|
reading: '',
|
||||||
|
headword: '',
|
||||||
|
startPos: 2,
|
||||||
|
endPos: 4,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: '級',
|
||||||
|
reading: '',
|
||||||
|
headword: '',
|
||||||
|
startPos: 4,
|
||||||
|
endPos: 5,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
jlptLevel: 'N3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: '頻度',
|
||||||
|
reading: '',
|
||||||
|
headword: '',
|
||||||
|
startPos: 5,
|
||||||
|
endPos: 7,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
frequencyRank: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markup = serializeSubtitleMarkup(payload, frequencyOptions);
|
||||||
|
assert.match(markup, /word word-known/);
|
||||||
|
assert.match(markup, /word word-n-plus-one/);
|
||||||
|
assert.match(markup, /word word-jlpt-n3/);
|
||||||
|
assert.match(markup, /word word-frequency-band-1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
|
||||||
|
const payload: SubtitleData = {
|
||||||
|
text: '字幕',
|
||||||
|
tokens: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions);
|
||||||
|
assert.deepEqual(JSON.parse(raw), { sentence: '字幕' });
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import type { MergedToken, SubtitleData } from '../../types';
|
||||||
|
|
||||||
const logger = createLogger('main:subtitle-ws');
|
const logger = createLogger('main:subtitle-ws');
|
||||||
|
|
||||||
@@ -11,18 +12,117 @@ export function hasMpvWebsocketPlugin(): boolean {
|
|||||||
return fs.existsSync(mpvWebsocketPath);
|
return fs.existsSync(mpvWebsocketPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubtitleWebsocketFrequencyOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
topX: number;
|
||||||
|
mode: 'single' | 'banded';
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFrequencyClass(
|
||||||
|
token: MergedToken,
|
||||||
|
options: SubtitleWebsocketFrequencyOptions,
|
||||||
|
): string | null {
|
||||||
|
if (!options.enabled) return null;
|
||||||
|
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) return null;
|
||||||
|
|
||||||
|
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
||||||
|
const topX = Math.max(1, Math.floor(options.topX));
|
||||||
|
if (rank > topX) return null;
|
||||||
|
|
||||||
|
if (options.mode === 'banded') {
|
||||||
|
const band = Math.min(5, Math.max(1, Math.ceil((rank / topX) * 5)));
|
||||||
|
return `word-frequency-band-${band}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'word-frequency-single';
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequencyOptions): string {
|
||||||
|
const classes = ['word'];
|
||||||
|
|
||||||
|
if (token.isNPlusOneTarget) {
|
||||||
|
classes.push('word-n-plus-one');
|
||||||
|
} else if (token.isKnown) {
|
||||||
|
classes.push('word-known');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.jlptLevel) {
|
||||||
|
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.isKnown && !token.isNPlusOneTarget) {
|
||||||
|
const frequencyClass = computeFrequencyClass(token, options);
|
||||||
|
if (frequencyClass) {
|
||||||
|
classes.push(frequencyClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubtitleMarkup(
|
||||||
|
payload: SubtitleData,
|
||||||
|
options: SubtitleWebsocketFrequencyOptions,
|
||||||
|
): string {
|
||||||
|
if (!payload.tokens || payload.tokens.length === 0) {
|
||||||
|
return escapeHtml(payload.text).replaceAll('\n', '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (const token of payload.tokens) {
|
||||||
|
const klass = computeWordClass(token, options);
|
||||||
|
const parts = token.surface.split('\n');
|
||||||
|
for (let index = 0; index < parts.length; index += 1) {
|
||||||
|
if (parts[index]) {
|
||||||
|
chunks.push(`<span class="${klass}">${escapeHtml(parts[index])}</span>`);
|
||||||
|
}
|
||||||
|
if (index < parts.length - 1) {
|
||||||
|
chunks.push('<br>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubtitleWebsocketMessage(
|
||||||
|
payload: SubtitleData,
|
||||||
|
options: SubtitleWebsocketFrequencyOptions,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) });
|
||||||
|
}
|
||||||
|
|
||||||
export class SubtitleWebSocket {
|
export class SubtitleWebSocket {
|
||||||
private server: WebSocket.Server | null = null;
|
private server: WebSocket.Server | null = null;
|
||||||
|
private latestMessage = '';
|
||||||
|
|
||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
return this.server !== null;
|
return this.server !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasClients(): boolean {
|
||||||
|
return (this.server?.clients.size ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public start(port: number, getCurrentSubtitleText: () => string): void {
|
public start(port: number, getCurrentSubtitleText: () => string): void {
|
||||||
this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
|
this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
|
||||||
|
|
||||||
this.server.on('connection', (ws: WebSocket) => {
|
this.server.on('connection', (ws: WebSocket) => {
|
||||||
logger.info('WebSocket client connected');
|
logger.info('WebSocket client connected');
|
||||||
|
if (this.latestMessage) {
|
||||||
|
ws.send(this.latestMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentText = getCurrentSubtitleText();
|
const currentText = getCurrentSubtitleText();
|
||||||
if (currentText) {
|
if (currentText) {
|
||||||
ws.send(JSON.stringify({ sentence: currentText }));
|
ws.send(JSON.stringify({ sentence: currentText }));
|
||||||
@@ -36,9 +136,10 @@ export class SubtitleWebSocket {
|
|||||||
logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
|
logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcast(text: string): void {
|
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
|
||||||
if (!this.server) return;
|
if (!this.server) return;
|
||||||
const message = JSON.stringify({ sentence: text });
|
const message = serializeSubtitleWebsocketMessage(payload, options);
|
||||||
|
this.latestMessage = message;
|
||||||
for (const client of this.server.clients) {
|
for (const client of this.server.clients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(message);
|
client.send(message);
|
||||||
@@ -51,5 +152,6 @@ export class SubtitleWebSocket {
|
|||||||
this.server.close();
|
this.server.close();
|
||||||
this.server = null;
|
this.server = null;
|
||||||
}
|
}
|
||||||
|
this.latestMessage = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
287
src/main.ts
287
src/main.ts
@@ -158,6 +158,18 @@ import {
|
|||||||
} from './main/runtime/jellyfin-remote-session-lifecycle';
|
} from './main/runtime/jellyfin-remote-session-lifecycle';
|
||||||
import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler';
|
import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler';
|
||||||
import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks';
|
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 {
|
import {
|
||||||
buildRestartRequiredConfigMessage,
|
buildRestartRequiredConfigMessage,
|
||||||
createConfigHotReloadAppliedHandler,
|
createConfigHotReloadAppliedHandler,
|
||||||
@@ -460,13 +472,18 @@ const subsyncRuntime = createMainSubsyncRuntime({
|
|||||||
let appTray: Tray | null = null;
|
let appTray: Tray | null = null;
|
||||||
const subtitleProcessingController = createSubtitleProcessingController({
|
const subtitleProcessingController = createSubtitleProcessingController({
|
||||||
tokenizeSubtitle: async (text: string) => {
|
tokenizeSubtitle: async (text: string) => {
|
||||||
if (getOverlayWindows().length === 0) {
|
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await tokenizeSubtitle(text);
|
return await tokenizeSubtitle(text);
|
||||||
},
|
},
|
||||||
emitSubtitle: (payload) => {
|
emitSubtitle: (payload) => {
|
||||||
broadcastToOverlayWindows('subtitle:set', 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) => {
|
logDebug: (message) => {
|
||||||
logger.debug(`[subtitle-processing] ${message}`);
|
logger.debug(`[subtitle-processing] ${message}`);
|
||||||
@@ -1871,12 +1888,12 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
|
|||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
})(args);
|
})(args);
|
||||||
|
|
||||||
handleCliCommandRuntimeServiceWithContext(args, source, {
|
const cliContext = createCliCommandContext({
|
||||||
getSocketPath: () => appState.mpvSocketPath,
|
getSocketPath: () => appState.mpvSocketPath,
|
||||||
setSocketPath: (socketPath: string) => {
|
setSocketPath: (socketPath: string) => {
|
||||||
appState.mpvSocketPath = socketPath;
|
appState.mpvSocketPath = socketPath;
|
||||||
},
|
},
|
||||||
getClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
showOsd: (text: string) => showMpvOsd(text),
|
showOsd: (text: string) => showMpvOsd(text),
|
||||||
texthookerService,
|
texthookerService,
|
||||||
getTexthookerPort: () => appState.texthookerPort,
|
getTexthookerPort: () => appState.texthookerPort,
|
||||||
@@ -1884,11 +1901,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
|
|||||||
appState.texthookerPort = port;
|
appState.texthookerPort = port;
|
||||||
},
|
},
|
||||||
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
|
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
|
||||||
openInBrowser: (url: string) => {
|
openExternal: (url: string) => shell.openExternal(url),
|
||||||
void shell.openExternal(url).catch((error) => {
|
logBrowserOpenError: (url: string, error: unknown) =>
|
||||||
logger.error(`Failed to open browser for texthooker URL: ${url}`, error);
|
logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
|
||||||
});
|
|
||||||
},
|
|
||||||
isOverlayInitialized: () => appState.overlayRuntimeInitialized,
|
isOverlayInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
initializeOverlay: () => initializeOverlayRuntime(),
|
initializeOverlay: () => initializeOverlayRuntime(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
@@ -1920,16 +1935,11 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
|
|||||||
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
|
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
|
||||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
||||||
log: (message: string) => {
|
logInfo: (message: string) => logger.info(message),
|
||||||
logger.info(message);
|
logWarn: (message: string) => logger.warn(message),
|
||||||
},
|
logError: (message: string, err: unknown) => logger.error(message, err),
|
||||||
warn: (message: string) => {
|
|
||||||
logger.warn(message);
|
|
||||||
},
|
|
||||||
error: (message: string, err: unknown) => {
|
|
||||||
logger.error(message, err);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInitialArgs(): void {
|
function handleInitialArgs(): void {
|
||||||
@@ -1946,104 +1956,119 @@ function handleInitialArgs(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
|
function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
|
||||||
mpvClient.on('connection-change', ({ connected }) => {
|
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
|
||||||
if (connected) return;
|
reportJellyfinRemoteStopped: () => {
|
||||||
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) {
|
|
||||||
void reportJellyfinRemoteStopped();
|
void reportJellyfinRemoteStopped();
|
||||||
}
|
},
|
||||||
const mediaKey = getCurrentAnilistMediaKey();
|
hasInitialJellyfinPlayArg: () => Boolean(appState.initialArgs?.jellyfinPlay),
|
||||||
resetAnilistMediaTracking(mediaKey);
|
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
if (mediaKey) {
|
isQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
|
||||||
void maybeProbeAnilistDuration(mediaKey);
|
scheduleQuitCheck: (callback) => {
|
||||||
void ensureAnilistMediaGuess(mediaKey);
|
setTimeout(callback, 500);
|
||||||
}
|
},
|
||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
|
||||||
|
quitApp: () => app.quit(),
|
||||||
});
|
});
|
||||||
mpvClient.on('media-title-change', ({ title }) => {
|
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
||||||
mediaRuntime.updateCurrentMediaTitle(title);
|
recordImmersionSubtitleLine: (text, start, end) => {
|
||||||
anilistCurrentMediaGuess = null;
|
appState.immersionTracker?.recordSubtitleLine(text, start, end);
|
||||||
anilistCurrentMediaGuessPromise = null;
|
},
|
||||||
appState.immersionTracker?.handleMediaTitleUpdate(title);
|
hasSubtitleTimingTracker: () => Boolean(appState.subtitleTimingTracker),
|
||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
recordSubtitleTiming: (text, start, end) => {
|
||||||
});
|
appState.subtitleTimingTracker?.recordSubtitle(text, start, end);
|
||||||
mpvClient.on('time-pos-change', ({ time }) => {
|
},
|
||||||
appState.immersionTracker?.recordPlaybackPosition(time);
|
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
|
||||||
void reportJellyfinRemoteProgress(false);
|
logError: (message, error) => logger.error(message, error),
|
||||||
});
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
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 {
|
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||||
const mpvClient = new MpvIpcClient(appState.mpvSocketPath, {
|
return createMpvClientRuntimeServiceFactory({
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
createClient: MpvIpcClient,
|
||||||
autoStartOverlay: appState.autoStartOverlay,
|
socketPath: appState.mpvSocketPath,
|
||||||
setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
|
options: {
|
||||||
shouldBindVisibleOverlayToMpvSubVisibility: () =>
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
|
autoStartOverlay: appState.autoStartOverlay,
|
||||||
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
|
||||||
getReconnectTimer: () => appState.reconnectTimer,
|
shouldBindVisibleOverlayToMpvSubVisibility: () =>
|
||||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
|
||||||
appState.reconnectTimer = timer;
|
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getReconnectTimer: () => appState.reconnectTimer,
|
||||||
|
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
||||||
|
appState.reconnectTimer = timer;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
|
||||||
bindMpvClientEventHandlers(mpvClient);
|
})();
|
||||||
mpvClient.connect();
|
|
||||||
return mpvClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
|
||||||
const { next, changed } = applyMpvSubtitleRenderMetricsPatch(
|
updateMpvSubtitleRenderMetricsRuntime(patch);
|
||||||
appState.mpvSubtitleRenderMetrics,
|
|
||||||
patch,
|
|
||||||
);
|
|
||||||
if (!changed) return;
|
|
||||||
appState.mpvSubtitleRenderMetrics = next;
|
|
||||||
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', appState.mpvSubtitleRenderMetrics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
|
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 launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler({
|
||||||
const startedAtMs = Date.now();
|
now: () => Date.now(),
|
||||||
void task()
|
logDebug: (message) => logger.debug(message),
|
||||||
.then(() => {
|
logWarn: (message) => logger.warn(message),
|
||||||
logger.debug(`[startup-warmup] ${label} completed in ${Date.now() - startedAtMs}ms`);
|
});
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.warn(`[startup-warmup] ${label} failed: ${(error as Error).message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startBackgroundWarmups(): void {
|
const startBackgroundWarmups = createStartBackgroundWarmupsHandler({
|
||||||
if (backgroundWarmupsStarted) {
|
getStarted: () => backgroundWarmupsStarted,
|
||||||
return;
|
setStarted: (started) => {
|
||||||
}
|
backgroundWarmupsStarted = started;
|
||||||
if (appState.texthookerOnlyMode) {
|
},
|
||||||
return;
|
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||||
}
|
launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
|
||||||
|
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
|
||||||
backgroundWarmupsStarted = true;
|
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}),
|
||||||
launchBackgroundWarmupTask('mecab', async () => {
|
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
|
||||||
await createMecabTokenizerAndCheck();
|
shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect,
|
||||||
});
|
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
|
||||||
launchBackgroundWarmupTask('yomitan-extension', async () => {
|
});
|
||||||
await ensureYomitanExtensionLoaded();
|
|
||||||
});
|
|
||||||
launchBackgroundWarmupTask('subtitle-dictionaries', async () => {
|
|
||||||
await prewarmSubtitleDictionaries();
|
|
||||||
});
|
|
||||||
if (getResolvedConfig().jellyfin.remoteControlAutoConnect) {
|
|
||||||
launchBackgroundWarmupTask('jellyfin-remote-session', async () => {
|
|
||||||
await startJellyfinRemoteSession();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
|
function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
|
||||||
overlayManager.setOverlayWindowBounds('visible', geometry);
|
overlayManager.setOverlayWindowBounds('visible', geometry);
|
||||||
|
|||||||
Reference in New Issue
Block a user