mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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: [] }]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@ export interface AnkiConnectConfig {
|
||||
export interface SubtitleStyleConfig {
|
||||
enableJlpt?: boolean;
|
||||
preserveLineBreaks?: boolean;
|
||||
hoverTokenColor?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
|
||||
Reference in New Issue
Block a user