fix(mpv): stabilize hover token subtitle highlighting

# Conflicts:
#	src/core/services/ipc.ts
#	src/main.ts
This commit is contained in:
2026-02-21 22:28:09 -08:00
parent 75c3b15792
commit 8b8a99dc79
15 changed files with 903 additions and 25 deletions

View File

@@ -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: () => {},

View File

@@ -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();
});

View File

@@ -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>;
}

View File

@@ -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,

View File

@@ -56,6 +56,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},

View 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);
});

View 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) });
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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;
}