feat(subtitles): improve mpv hovered-token highlighting flow

- add subtitleStyle.hoverTokenColor config default + validation

- normalize hover color payloads and propagate configured color to mpv runtime

- refresh invisible overlay tokenization with current subtitle text and tighten hover overlay cleanup hooks

- record TASK-98 and subagent coordination updates
This commit is contained in:
2026-02-21 22:20:56 -08:00
parent 430c4e7120
commit 01f01f18e3
17 changed files with 244 additions and 15 deletions

View File

@@ -25,6 +25,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25);
@@ -95,6 +96,44 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
);
});
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenColor": "#c6a0f6"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenColor, '#c6a0f6');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenColor": "purple"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenColor'),
);
});
test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir();
fs.writeFileSync(

View File

@@ -4,6 +4,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: {
enableJlpt: false,
preserveLineBreaks: false,
hoverTokenColor: '#c6a0f6',
fontFamily:
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
fontSize: 35,

View File

@@ -21,6 +21,12 @@ export function buildSubtitleConfigOptionRegistry(
'Preserve line breaks in visible overlay subtitle rendering. ' +
'When false, line breaks are flattened to spaces for a single-line flow.',
},
{
path: 'subtitleStyle.hoverTokenColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
description: 'Hex color used for hovered subtitle token highlight in mpv.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',

View File

@@ -99,6 +99,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
resolved.subtitleStyle = {
...resolved.subtitleStyle,
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
@@ -140,6 +141,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor);
if (hoverTokenColor !== undefined) {
resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
} else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
resolved.subtitleStyle.hoverTokenColor = fallbackSubtitleStyleHoverTokenColor;
warn(
'subtitleStyle.hoverTokenColor',
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
resolved.subtitleStyle.hoverTokenColor,
'Expected hex color.',
);
}
const frequencyDictionary = isObject(
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
)

View File

@@ -99,3 +99,16 @@ test('subtitle processing can refresh current subtitle without text change', asy
{ text: 'same', tokens: [] },
]);
});
test('subtitle processing refresh can use explicit text override', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.refreshCurrentSubtitle('initial');
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]);
});

View File

@@ -9,7 +9,7 @@ export interface SubtitleProcessingControllerDeps {
export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: () => void;
refreshCurrentSubtitle: (textOverride?: string) => void;
}
export function createSubtitleProcessingController(
@@ -87,7 +87,10 @@ export function createSubtitleProcessingController(
latestText = text;
processLatest();
},
refreshCurrentSubtitle: () => {
refreshCurrentSubtitle: (textOverride?: string) => {
if (typeof textOverride === 'string') {
latestText = textOverride;
}
if (!latestText.trim()) {
return;
}

View File

@@ -654,6 +654,7 @@ const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({
getCurrentSubtitleData: () => appState.currentSubtitleData,
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex,
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision,
getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null,
});
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(),
@@ -2973,7 +2974,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
function setInvisibleOverlayVisible(visible: boolean): void {
setInvisibleOverlayVisibleHandler(visible);
if (visible) {
subtitleProcessingController.refreshCurrentSubtitle();
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
}

View File

@@ -74,7 +74,18 @@ test('buildHoveredTokenPayload normalizes metadata and strips empty tokens', ()
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');
assert.equal(payload.colors.hover, 'C6A0F6');
});
test('buildHoveredTokenPayload normalizes hover color override', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 1,
revision: 7,
hoverColor: '#c6a0f6',
});
assert.equal(payload.colors.hover, 'C6A0F6');
});
test('buildHoveredTokenMessageCommand sends script-message-to subminer payload', () => {
@@ -111,6 +122,7 @@ test('createApplyHoveredTokenOverlayHandler sends clear payload when hovered tok
getCurrentSubtitleData: () => SUBTITLE,
getHoveredTokenIndex: () => null,
getHoveredSubtitleRevision: () => 3,
getHoverTokenColor: () => null,
});
apply();
@@ -134,6 +146,7 @@ test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover i
getCurrentSubtitleData: () => SUBTITLE,
getHoveredTokenIndex: () => 0,
getHoveredSubtitleRevision: () => 3,
getHoverTokenColor: () => '#c6a0f6',
});
apply();
@@ -142,6 +155,7 @@ test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover i
assert.equal(parsed.hoveredTokenIndex, 0);
assert.equal(parsed.subtitle, '昨日は雨だった。');
assert.equal(parsed.tokens.length, 4);
assert.equal(parsed.colors.hover, 'C6A0F6');
assert.equal(commands[0]?.[0], 'script-message-to');
assert.equal(commands[0]?.[1], HOVER_SCRIPT_NAME);
});

View File

@@ -3,7 +3,7 @@ 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_HOVER_TOKEN_COLOR = 'C6A0F6';
const DEFAULT_TOKEN_COLOR = 'FFFFFF';
export type HoverPayloadToken = {
@@ -28,8 +28,17 @@ type HoverTokenInput = {
subtitle: SubtitleData | null;
hoveredTokenIndex: number | null;
revision: number;
hoverColor?: string | null;
};
function normalizeHexColor(color: string | null | undefined, fallback: string): string {
if (typeof color !== 'string') {
return fallback;
}
const normalized = color.trim().replace(/^#/, '').toUpperCase();
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : fallback;
}
function sanitizeSubtitleText(text: string): string {
return text
.replace(/\\N/g, '\n')
@@ -51,7 +60,7 @@ function hasHoveredToken(subtitle: SubtitleData | null, hoveredTokenIndex: numbe
}
export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayload {
const { subtitle, hoveredTokenIndex, revision } = input;
const { subtitle, hoveredTokenIndex, revision, hoverColor } = input;
const tokens: HoverPayloadToken[] = [];
@@ -83,7 +92,7 @@ export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayl
tokens,
colors: {
base: DEFAULT_TOKEN_COLOR,
hover: DEFAULT_HOVER_TOKEN_COLOR,
hover: normalizeHexColor(hoverColor, DEFAULT_HOVER_TOKEN_COLOR),
},
};
}
@@ -105,6 +114,7 @@ export function createApplyHoveredTokenOverlayHandler(deps: {
getCurrentSubtitleData: () => SubtitleData | null;
getHoveredTokenIndex: () => number | null;
getHoveredSubtitleRevision: () => number;
getHoverTokenColor: () => string | null;
}) {
return (): void => {
const mpvClient = deps.getMpvClient();
@@ -115,10 +125,12 @@ export function createApplyHoveredTokenOverlayHandler(deps: {
const subtitle = deps.getCurrentSubtitleData();
const hoveredTokenIndex = deps.getHoveredTokenIndex();
const revision = deps.getHoveredSubtitleRevision();
const hoverColor = deps.getHoverTokenColor();
const payload = buildHoveredTokenPayload({
subtitle: subtitle && hasHoveredToken(subtitle, hoveredTokenIndex) ? subtitle : null,
hoveredTokenIndex: hoveredTokenIndex,
revision,
hoverColor,
});
mpvClient.send({ command: buildHoveredTokenMessageCommand(payload) });

View File

@@ -54,13 +54,13 @@ export function applyVerticalPosition(
bottomInset: number;
marginY: number;
effectiveFontSize: number;
borderPx: number;
shadowPx: number;
vAlign: 0 | 1 | 2;
},
): void {
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const multiline = lineCount > 1;
const baselineCompensationFactor = lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7;
const baselineCompensationPx = Math.max(0, params.effectiveFontSize * baselineCompensationFactor);
const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset);
const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5);
if (params.vAlign === 2) {
ctx.dom.subtitleContainer.style.top = `${Math.max(
@@ -78,9 +78,9 @@ export function applyVerticalPosition(
return;
}
const subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
const effectiveMargin = Math.max(params.marginY, subPosMargin);
const bottomPx = Math.max(0, params.bottomInset + effectiveMargin + baselineCompensationPx);
const anchorY =
params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx;
const bottomPx = Math.max(0, params.renderAreaHeight - anchorY);
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;

View File

@@ -33,6 +33,7 @@ export function createMpvSubtitleLayoutController(
applySubtitleFontSize(geometry.effectiveFontSize);
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel;
const effectiveShadowOffset = metrics.subShadowOffset * geometry.pxPerScaledPixel;
document.documentElement.style.setProperty('--sub-border-size', `${effectiveBorderSize}px`);
@@ -53,6 +54,8 @@ export function createMpvSubtitleLayoutController(
bottomInset: geometry.bottomInset,
marginY: geometry.marginY,
effectiveFontSize: geometry.effectiveFontSize,
borderPx: effectiveBorderSize,
shadowPx: effectiveShadowOffset,
vAlign: alignment.vAlign,
});

View File

@@ -271,6 +271,7 @@ export interface AnkiConnectConfig {
export interface SubtitleStyleConfig {
enableJlpt?: boolean;
preserveLineBreaks?: boolean;
hoverTokenColor?: string;
fontFamily?: string;
fontSize?: number;
fontColor?: string;