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

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