mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(mpv): stabilize hover token subtitle highlighting
# Conflicts: # src/core/services/ipc.ts # src/main.ts
This commit is contained in:
@@ -64,6 +64,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
||||
clearAnilistToken: () => {
|
||||
calls.push('clearAnilistToken');
|
||||
@@ -137,6 +138,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
return { ok: true };
|
||||
},
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
@@ -212,6 +214,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface IpcServiceDeps {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -118,6 +119,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -180,6 +182,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
cycleRuntimeOption: options.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
||||
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
|
||||
getAnilistStatus: options.getAnilistStatus,
|
||||
clearAnilistToken: options.clearAnilistToken,
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
@@ -355,6 +358,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.reportOverlayContentBounds(payload);
|
||||
});
|
||||
|
||||
ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => {
|
||||
if (tokenIndex === null) {
|
||||
deps.reportHoveredSubtitleToken(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) {
|
||||
return;
|
||||
}
|
||||
deps.reportHoveredSubtitleToken(tokenIndex as number);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
||||
return deps.getAnilistStatus();
|
||||
});
|
||||
|
||||
72
src/main.ts
72
src/main.ts
@@ -92,8 +92,16 @@ import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './mai
|
||||
import {
|
||||
getConfiguredJellyfinSession,
|
||||
type ActiveJellyfinRemotePlaybackState,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
|
||||
createBuildHandleJellyfinRemotePlayMainDepsHandler,
|
||||
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
|
||||
createBuildReportJellyfinRemoteProgressMainDepsHandler,
|
||||
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
|
||||
} from './main/runtime/domains/jellyfin';
|
||||
import { createBuildSubtitleProcessingControllerMainDepsHandler } from './main/runtime/domains/startup';
|
||||
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight';
|
||||
import {
|
||||
createBuildAnilistStateRuntimeMainDepsHandler,
|
||||
createBuildConfigDerivedRuntimeMainDepsHandler,
|
||||
@@ -641,6 +649,12 @@ const appState = createAppState({
|
||||
mpvSocketPath: getDefaultSocketPath(),
|
||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||
});
|
||||
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentSubtitleData: () => appState.currentSubtitleData,
|
||||
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex,
|
||||
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision,
|
||||
});
|
||||
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
|
||||
@@ -703,26 +717,36 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH
|
||||
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) => {
|
||||
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}`);
|
||||
},
|
||||
now: () => Date.now(),
|
||||
});
|
||||
const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler();
|
||||
tokenizeSubtitle: async (text: string) => {
|
||||
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
|
||||
return null;
|
||||
}
|
||||
return await tokenizeSubtitle(text);
|
||||
},
|
||||
emitSubtitle: (payload) => {
|
||||
const previousSubtitleText = appState.currentSubtitleData?.text ?? null;
|
||||
const nextSubtitleText = payload?.text ?? null;
|
||||
const subtitleChanged = previousSubtitleText !== nextSubtitleText;
|
||||
appState.currentSubtitleData = payload;
|
||||
if (subtitleChanged) {
|
||||
appState.hoveredSubtitleTokenIndex = null;
|
||||
appState.hoveredSubtitleRevision += 1;
|
||||
applyHoveredTokenOverlay();
|
||||
}
|
||||
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}`);
|
||||
},
|
||||
now: () => Date.now(),
|
||||
});
|
||||
const subtitleProcessingControllerMainDeps =
|
||||
buildSubtitleProcessingControllerMainDepsHandler();
|
||||
const subtitleProcessingController = createSubtitleProcessingController(
|
||||
subtitleProcessingControllerMainDeps,
|
||||
);
|
||||
@@ -2671,6 +2695,9 @@ const {
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
overlayContentMeasurementStore.report(payload);
|
||||
},
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
|
||||
reportHoveredSubtitleToken(tokenIndex);
|
||||
},
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
|
||||
openAnilistSetup: () => openAnilistSetupWindow(),
|
||||
@@ -2967,6 +2994,11 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
|
||||
handleMpvCommandFromIpcHandler(command);
|
||||
}
|
||||
|
||||
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
|
||||
appState.hoveredSubtitleTokenIndex = tokenIndex;
|
||||
applyHoveredTokenOverlay();
|
||||
}
|
||||
|
||||
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
||||
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
|
||||
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
|
||||
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
|
||||
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
|
||||
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
|
||||
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
@@ -219,6 +220,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
setRuntimeOption: params.setRuntimeOption,
|
||||
cycleRuntimeOption: params.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: params.reportOverlayContentBounds,
|
||||
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
|
||||
getAnilistStatus: params.getAnilistStatus,
|
||||
clearAnilistToken: params.clearAnilistToken,
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
|
||||
@@ -56,6 +56,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
|
||||
147
src/main/runtime/mpv-hover-highlight.test.ts
Normal file
147
src/main/runtime/mpv-hover-highlight.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PartOfSpeech, type SubtitleData } from '../../types';
|
||||
import {
|
||||
HOVER_TOKEN_MESSAGE,
|
||||
HOVER_SCRIPT_NAME,
|
||||
buildHoveredTokenMessageCommand,
|
||||
buildHoveredTokenPayload,
|
||||
createApplyHoveredTokenOverlayHandler,
|
||||
} from './mpv-hover-highlight';
|
||||
|
||||
const SUBTITLE: SubtitleData = {
|
||||
text: '昨日は雨だった。',
|
||||
tokens: [
|
||||
{
|
||||
surface: '昨日',
|
||||
reading: 'きのう',
|
||||
headword: '昨日',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
surface: 'は',
|
||||
reading: 'は',
|
||||
headword: 'は',
|
||||
startPos: 2,
|
||||
endPos: 3,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
isMerged: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
surface: '雨',
|
||||
reading: 'あめ',
|
||||
headword: '雨',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: true,
|
||||
},
|
||||
{
|
||||
surface: 'だった。',
|
||||
reading: 'だった。',
|
||||
headword: 'だ',
|
||||
startPos: 4,
|
||||
endPos: 8,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('buildHoveredTokenPayload normalizes metadata and strips empty tokens', () => {
|
||||
const payload = buildHoveredTokenPayload({
|
||||
subtitle: SUBTITLE,
|
||||
hoveredTokenIndex: 2,
|
||||
revision: 5,
|
||||
});
|
||||
|
||||
assert.equal(payload.revision, 5);
|
||||
assert.equal(payload.subtitle, '昨日は雨だった。');
|
||||
assert.equal(payload.hoveredTokenIndex, 2);
|
||||
assert.equal(payload.tokens.length, 4);
|
||||
assert.equal(payload.tokens[0]?.text, '昨日');
|
||||
assert.equal(payload.tokens[0]?.index, 0);
|
||||
assert.equal(payload.tokens[1]?.index, 1);
|
||||
assert.equal(payload.colors.hover, 'E7C06A');
|
||||
});
|
||||
|
||||
test('buildHoveredTokenMessageCommand sends script-message-to subminer payload', () => {
|
||||
const payload = buildHoveredTokenPayload({
|
||||
subtitle: SUBTITLE,
|
||||
hoveredTokenIndex: 0,
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
const command = buildHoveredTokenMessageCommand(payload);
|
||||
|
||||
assert.equal(command[0], 'script-message-to');
|
||||
assert.equal(command[1], HOVER_SCRIPT_NAME);
|
||||
assert.equal(command[2], HOVER_TOKEN_MESSAGE);
|
||||
|
||||
const raw = command[3] as string;
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.equal(parsed.revision, 1);
|
||||
assert.equal(parsed.hoveredTokenIndex, 0);
|
||||
assert.equal(parsed.subtitle, '昨日は雨だった。');
|
||||
assert.equal(parsed.tokens.length, 4);
|
||||
});
|
||||
|
||||
test('createApplyHoveredTokenOverlayHandler sends clear payload when hovered token is missing', () => {
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
const apply = createApplyHoveredTokenOverlayHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
send: ({ command }: { command: (string | number)[] }) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
getCurrentSubtitleData: () => SUBTITLE,
|
||||
getHoveredTokenIndex: () => null,
|
||||
getHoveredSubtitleRevision: () => 3,
|
||||
});
|
||||
|
||||
apply();
|
||||
|
||||
const parsed = JSON.parse(commands[0]?.[3] as string);
|
||||
assert.equal(parsed.hoveredTokenIndex, null);
|
||||
assert.equal(parsed.subtitle, null);
|
||||
assert.equal(parsed.tokens.length, 0);
|
||||
});
|
||||
|
||||
test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover is active', () => {
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
const apply = createApplyHoveredTokenOverlayHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
send: ({ command }: { command: (string | number)[] }) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
getCurrentSubtitleData: () => SUBTITLE,
|
||||
getHoveredTokenIndex: () => 0,
|
||||
getHoveredSubtitleRevision: () => 3,
|
||||
});
|
||||
|
||||
apply();
|
||||
|
||||
const parsed = JSON.parse(commands[0]?.[3] as string);
|
||||
assert.equal(parsed.hoveredTokenIndex, 0);
|
||||
assert.equal(parsed.subtitle, '昨日は雨だった。');
|
||||
assert.equal(parsed.tokens.length, 4);
|
||||
assert.equal(commands[0]?.[0], 'script-message-to');
|
||||
assert.equal(commands[0]?.[1], HOVER_SCRIPT_NAME);
|
||||
});
|
||||
126
src/main/runtime/mpv-hover-highlight.ts
Normal file
126
src/main/runtime/mpv-hover-highlight.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export const HOVER_SCRIPT_NAME = 'subminer';
|
||||
export const HOVER_TOKEN_MESSAGE = 'subminer-hover-token';
|
||||
|
||||
const DEFAULT_HOVER_TOKEN_COLOR = 'E7C06A';
|
||||
const DEFAULT_TOKEN_COLOR = 'FFFFFF';
|
||||
|
||||
export type HoverPayloadToken = {
|
||||
text: string;
|
||||
index: number;
|
||||
startPos: number | null;
|
||||
endPos: number | null;
|
||||
};
|
||||
|
||||
export type HoverTokenPayload = {
|
||||
revision: number;
|
||||
subtitle: string | null;
|
||||
hoveredTokenIndex: number | null;
|
||||
tokens: HoverPayloadToken[];
|
||||
colors: {
|
||||
base: string;
|
||||
hover: string;
|
||||
};
|
||||
};
|
||||
|
||||
type HoverTokenInput = {
|
||||
subtitle: SubtitleData | null;
|
||||
hoveredTokenIndex: number | null;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
function sanitizeSubtitleText(text: string): string {
|
||||
return text
|
||||
.replace(/\\N/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\{[^}]*\}/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeTokenSurface(surface: unknown): string {
|
||||
return typeof surface === 'string' ? surface : '';
|
||||
}
|
||||
|
||||
function hasHoveredToken(subtitle: SubtitleData | null, hoveredTokenIndex: number | null): boolean {
|
||||
if (!subtitle || hoveredTokenIndex === null || hoveredTokenIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return subtitle.tokens?.some((token, index) => index === hoveredTokenIndex) ?? false;
|
||||
}
|
||||
|
||||
export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayload {
|
||||
const { subtitle, hoveredTokenIndex, revision } = input;
|
||||
|
||||
const tokens: HoverPayloadToken[] = [];
|
||||
|
||||
if (subtitle?.tokens && subtitle.tokens.length > 0) {
|
||||
for (let tokenIndex = 0; tokenIndex < subtitle.tokens.length; tokenIndex += 1) {
|
||||
const token = subtitle.tokens[tokenIndex];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const surface = sanitizeTokenSurface(token?.surface);
|
||||
if (!surface || surface.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
text: surface,
|
||||
index: tokenIndex,
|
||||
startPos: Number.isFinite(token.startPos) ? token.startPos : null,
|
||||
endPos: Number.isFinite(token.endPos) ? token.endPos : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
revision,
|
||||
subtitle: subtitle ? sanitizeSubtitleText(subtitle.text) : null,
|
||||
hoveredTokenIndex:
|
||||
hoveredTokenIndex !== null && hoveredTokenIndex >= 0 ? hoveredTokenIndex : null,
|
||||
tokens,
|
||||
colors: {
|
||||
base: DEFAULT_TOKEN_COLOR,
|
||||
hover: DEFAULT_HOVER_TOKEN_COLOR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHoveredTokenMessageCommand(payload: HoverTokenPayload): (string | number)[] {
|
||||
return [
|
||||
'script-message-to',
|
||||
HOVER_SCRIPT_NAME,
|
||||
HOVER_TOKEN_MESSAGE,
|
||||
JSON.stringify(payload),
|
||||
];
|
||||
}
|
||||
|
||||
export function createApplyHoveredTokenOverlayHandler(deps: {
|
||||
getMpvClient: () => {
|
||||
connected: boolean;
|
||||
send: (payload: { command: (string | number)[] }) => boolean;
|
||||
} | null;
|
||||
getCurrentSubtitleData: () => SubtitleData | null;
|
||||
getHoveredTokenIndex: () => number | null;
|
||||
getHoveredSubtitleRevision: () => number;
|
||||
}) {
|
||||
return (): void => {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!mpvClient || !mpvClient.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitle = deps.getCurrentSubtitleData();
|
||||
const hoveredTokenIndex = deps.getHoveredTokenIndex();
|
||||
const revision = deps.getHoveredSubtitleRevision();
|
||||
const payload = buildHoveredTokenPayload({
|
||||
subtitle: subtitle && hasHoveredToken(subtitle, hoveredTokenIndex) ? subtitle : null,
|
||||
hoveredTokenIndex: hoveredTokenIndex,
|
||||
revision,
|
||||
});
|
||||
|
||||
mpvClient.send({ command: buildHoveredTokenMessageCommand(payload) });
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Keybinding,
|
||||
MpvSubtitleRenderMetrics,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
KikuFieldGroupingChoice,
|
||||
JlptLevel,
|
||||
@@ -152,6 +153,9 @@ export interface AppState {
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
hoveredSubtitleTokenIndex: number | null;
|
||||
hoveredSubtitleRevision: number;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
subtitlePosition: SubtitlePosition | null;
|
||||
currentMediaPath: string | null;
|
||||
@@ -221,6 +225,9 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
reconnectTimer: null,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
currentSubtitleData: null,
|
||||
hoveredSubtitleTokenIndex: null,
|
||||
hoveredSubtitleRevision: 0,
|
||||
windowTracker: null,
|
||||
subtitlePosition: null,
|
||||
currentMediaPath: null,
|
||||
|
||||
@@ -257,6 +257,9 @@ const electronAPI: ElectronAPI = {
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
||||
},
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
|
||||
ipcRenderer.send('subtitle-token-hover:set', tokenIndex);
|
||||
},
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.event.configHotReload,
|
||||
|
||||
@@ -8,6 +8,7 @@ export function createMouseHandlers(
|
||||
applyYPercent: (yPercent: number) => void;
|
||||
getCurrentYPercent: () => number;
|
||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||
reportHoveredTokenIndex: (tokenIndex: number | null) => void;
|
||||
},
|
||||
) {
|
||||
const wordSegmenter =
|
||||
@@ -191,6 +192,57 @@ export function createMouseHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function setupInvisibleTokenHoverReporter(): void {
|
||||
if (!ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
let pendingNullHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearPendingNullHoverTimer = (): void => {
|
||||
if (pendingNullHoverTimer !== null) {
|
||||
clearTimeout(pendingNullHoverTimer);
|
||||
pendingNullHoverTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const reportHoveredToken = (tokenIndex: number | null): void => {
|
||||
if (ctx.state.lastHoveredTokenIndex === tokenIndex) return;
|
||||
ctx.state.lastHoveredTokenIndex = tokenIndex;
|
||||
options.reportHoveredTokenIndex(tokenIndex);
|
||||
};
|
||||
|
||||
const queueNullHoveredToken = (): void => {
|
||||
if (pendingNullHoverTimer !== null) return;
|
||||
pendingNullHoverTimer = setTimeout(() => {
|
||||
pendingNullHoverTimer = null;
|
||||
reportHoveredToken(null);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Element)) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest<HTMLElement>('.word[data-token-index]');
|
||||
if (!target || !ctx.dom.subtitleRoot.contains(target)) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
const rawTokenIndex = target.dataset.tokenIndex;
|
||||
const tokenIndex = rawTokenIndex ? Number.parseInt(rawTokenIndex, 10) : Number.NaN;
|
||||
if (!Number.isInteger(tokenIndex) || tokenIndex < 0) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
clearPendingNullHoverTimer();
|
||||
reportHoveredToken(tokenIndex);
|
||||
});
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
|
||||
clearPendingNullHoverTimer();
|
||||
reportHoveredToken(null);
|
||||
});
|
||||
}
|
||||
|
||||
function setupResizeHandler(): void {
|
||||
window.addEventListener('resize', () => {
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
@@ -268,6 +320,7 @@ export function createMouseHandlers(
|
||||
handleMouseLeave,
|
||||
setupDragging,
|
||||
setupInvisibleHoverSelection,
|
||||
setupInvisibleTokenHoverReporter,
|
||||
setupResizeHandler,
|
||||
setupSelectionObserver,
|
||||
setupYomitanObserver,
|
||||
|
||||
@@ -131,6 +131,9 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
||||
applyYPercent: positioning.applyYPercent,
|
||||
getCurrentYPercent: positioning.getCurrentYPercent,
|
||||
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
||||
reportHoveredTokenIndex: (tokenIndex: number | null) => {
|
||||
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
|
||||
},
|
||||
});
|
||||
|
||||
let lastSubtitlePreview = '';
|
||||
@@ -307,6 +310,7 @@ async function init(): Promise<void> {
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
||||
|
||||
mouseHandlers.setupInvisibleHoverSelection();
|
||||
mouseHandlers.setupInvisibleTokenHoverReporter();
|
||||
positioning.setupInvisiblePositionEditHud();
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
|
||||
@@ -71,6 +71,7 @@ export type RendererState = {
|
||||
|
||||
lastHoverSelectionKey: string;
|
||||
lastHoverSelectionNode: Text | null;
|
||||
lastHoveredTokenIndex: number | null;
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
@@ -148,6 +149,7 @@ export function createRendererState(): RendererState {
|
||||
|
||||
lastHoverSelectionKey: '',
|
||||
lastHoverSelectionNode: null,
|
||||
lastHoveredTokenIndex: null,
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
|
||||
@@ -134,6 +134,7 @@ function renderWithTokens(
|
||||
const span = document.createElement('span');
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.textContent = token.surface;
|
||||
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
fragment.appendChild(span);
|
||||
@@ -143,7 +144,11 @@ function renderWithTokens(
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const surface = token.surface.replace(/\n/g, ' ');
|
||||
if (!surface) {
|
||||
continue;
|
||||
@@ -157,6 +162,7 @@ function renderWithTokens(
|
||||
const span = document.createElement('span');
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.textContent = surface;
|
||||
span.dataset.tokenIndex = String(index);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
fragment.appendChild(span);
|
||||
@@ -165,7 +171,9 @@ function renderWithTokens(
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
type SubtitleRenderSegment = { kind: 'text'; text: string } | { kind: 'token'; token: MergedToken };
|
||||
type SubtitleRenderSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'token'; token: MergedToken; tokenIndex: number };
|
||||
|
||||
export function alignTokensToSourceText(
|
||||
tokens: MergedToken[],
|
||||
@@ -178,7 +186,11 @@ export function alignTokensToSourceText(
|
||||
const segments: SubtitleRenderSegment[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const token of tokens) {
|
||||
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
|
||||
const token = tokens[tokenIndex];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const surface = token.surface;
|
||||
if (!surface || isWhitespaceOnly(surface)) {
|
||||
continue;
|
||||
@@ -195,7 +207,7 @@ export function alignTokensToSourceText(
|
||||
segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) });
|
||||
}
|
||||
|
||||
segments.push({ kind: 'token', token });
|
||||
segments.push({ kind: 'token', token, tokenIndex });
|
||||
cursor = foundIndex + surface.length;
|
||||
}
|
||||
|
||||
@@ -282,6 +294,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.dom.subtitleRoot.innerHTML = '';
|
||||
ctx.state.lastHoverSelectionKey = '';
|
||||
ctx.state.lastHoverSelectionNode = null;
|
||||
ctx.state.lastHoveredTokenIndex = null;
|
||||
|
||||
let text: string;
|
||||
let tokens: MergedToken[] | null;
|
||||
@@ -304,7 +317,17 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
1,
|
||||
normalizedInvisible.split('\n').length,
|
||||
);
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
text,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -710,6 +710,10 @@ export interface ConfigHotReloadPayload {
|
||||
secondarySubMode: SecondarySubMode;
|
||||
}
|
||||
|
||||
export interface SubtitleHoverTokenPayload {
|
||||
tokenIndex: number | null;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => 'visible' | 'invisible' | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -765,6 +769,7 @@ export interface ElectronAPI {
|
||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user