feat(subtitle-sidebar): add sidebar config surface (#28)

This commit is contained in:
2026-03-21 23:37:42 -07:00
committed by GitHub
parent eddf6f0456
commit 3a01cffc6b
66 changed files with 5241 additions and 426 deletions

View File

@@ -36,7 +36,7 @@ const {
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
@@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subsync,
startupWarmups,
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
jimaku,
anilist,

View File

@@ -1,6 +1,6 @@
import { ResolvedConfig } from '../../types';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
subtitleStyle: {
enableJlpt: false,
preserveLineBreaks: false,
@@ -57,4 +57,22 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
fontStyle: 'normal',
},
},
subtitleSidebar: {
enabled: false,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.95,
backgroundColor: 'rgba(73, 77, 100, 0.9)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};

View File

@@ -110,5 +110,102 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
},
{
path: 'subtitleSidebar.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.enabled,
description: 'Enable the subtitle sidebar feature for parsed subtitle sources.',
},
{
path: 'subtitleSidebar.autoOpen',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.autoOpen,
description: 'Automatically open the subtitle sidebar once during overlay startup.',
},
{
path: 'subtitleSidebar.layout',
kind: 'enum',
enumValues: ['overlay', 'embedded'],
defaultValue: defaultConfig.subtitleSidebar.layout,
description: 'Render the subtitle sidebar as a floating overlay or reserve space inside mpv.',
},
{
path: 'subtitleSidebar.toggleKey',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.toggleKey,
description: 'KeyboardEvent.code used to toggle the subtitle sidebar open and closed.',
},
{
path: 'subtitleSidebar.pauseVideoOnHover',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.pauseVideoOnHover,
description: 'Pause mpv while hovering the subtitle sidebar, then resume on leave.',
},
{
path: 'subtitleSidebar.autoScroll',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
},
{
path: 'subtitleSidebar.maxWidth',
kind: 'number',
defaultValue: defaultConfig.subtitleSidebar.maxWidth,
description: 'Maximum sidebar width in CSS pixels.',
},
{
path: 'subtitleSidebar.opacity',
kind: 'number',
defaultValue: defaultConfig.subtitleSidebar.opacity,
description: 'Base opacity applied to the sidebar shell.',
},
{
path: 'subtitleSidebar.backgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.backgroundColor,
description: 'Background color for the subtitle sidebar shell.',
},
{
path: 'subtitleSidebar.textColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.textColor,
description: 'Default cue text color in the subtitle sidebar.',
},
{
path: 'subtitleSidebar.fontFamily',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.fontFamily,
description: 'Font family used for subtitle sidebar cue text.',
},
{
path: 'subtitleSidebar.fontSize',
kind: 'number',
defaultValue: defaultConfig.subtitleSidebar.fontSize,
description: 'Base font size for subtitle sidebar cue text in CSS pixels.',
},
{
path: 'subtitleSidebar.timestampColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.timestampColor,
description: 'Timestamp color in the subtitle sidebar.',
},
{
path: 'subtitleSidebar.activeLineColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.activeLineColor,
description: 'Text color for the active subtitle cue.',
},
{
path: 'subtitleSidebar.activeLineBackgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.activeLineBackgroundColor,
description: 'Background color for the active subtitle cue.',
},
{
path: 'subtitleSidebar.hoverLineBackgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
description: 'Background color for hovered subtitle cues.',
},
];
}

View File

@@ -98,6 +98,12 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle',
},
{
title: 'Subtitle Sidebar',
description: ['Parsed-subtitle sidebar cue list styling, behavior, and toggle key.'],
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
key: 'subtitleSidebar',
},
];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [

View File

@@ -15,6 +15,22 @@ export function asBoolean(value: unknown): boolean | undefined {
}
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const cssColorKeywords = new Set([
'transparent',
'currentcolor',
'inherit',
'initial',
'unset',
'revert',
'revert-layer',
]);
const cssColorFunctionPattern = /^(?:rgba?|hsla?)\(\s*[^()]+?\s*\)$/i;
function supportsCssColor(text: string): boolean {
const css = (globalThis as { CSS?: { supports?: (property: string, value: string) => boolean } })
.CSS;
return css?.supports?.('color', text) ?? false;
}
export function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
@@ -22,6 +38,30 @@ export function asColor(value: unknown): string | undefined {
return hexColorPattern.test(text) ? text : undefined;
}
export function asCssColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const text = value.trim();
if (text.length === 0) {
return undefined;
}
if (supportsCssColor(text)) {
return text;
}
const normalized = text.toLowerCase();
if (
hexColorPattern.test(text) ||
cssColorKeywords.has(normalized) ||
cssColorFunctionPattern.test(text)
) {
return text;
}
return undefined;
}
export function asFrequencyBandedColors(
value: unknown,
): [string, string, string, string, string] | undefined {

View File

@@ -3,6 +3,7 @@ import { ResolveContext } from './context';
import {
asBoolean,
asColor,
asCssColor,
asFrequencyBandedColors,
asNumber,
asString,
@@ -418,4 +419,180 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
}
if (isObject(src.subtitleSidebar)) {
const fallback = { ...resolved.subtitleSidebar };
resolved.subtitleSidebar = {
...resolved.subtitleSidebar,
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
};
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
if (enabled !== undefined) {
resolved.subtitleSidebar.enabled = enabled;
} else if ((src.subtitleSidebar as { enabled?: unknown }).enabled !== undefined) {
resolved.subtitleSidebar.enabled = fallback.enabled;
warn(
'subtitleSidebar.enabled',
(src.subtitleSidebar as { enabled?: unknown }).enabled,
resolved.subtitleSidebar.enabled,
'Expected boolean.',
);
}
const autoOpen = asBoolean((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen);
if (autoOpen !== undefined) {
resolved.subtitleSidebar.autoOpen = autoOpen;
} else if ((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen !== undefined) {
resolved.subtitleSidebar.autoOpen = fallback.autoOpen;
warn(
'subtitleSidebar.autoOpen',
(src.subtitleSidebar as { autoOpen?: unknown }).autoOpen,
resolved.subtitleSidebar.autoOpen,
'Expected boolean.',
);
}
const layout = asString((src.subtitleSidebar as { layout?: unknown }).layout);
if (layout === 'overlay' || layout === 'embedded') {
resolved.subtitleSidebar.layout = layout;
} else if ((src.subtitleSidebar as { layout?: unknown }).layout !== undefined) {
resolved.subtitleSidebar.layout = fallback.layout;
warn(
'subtitleSidebar.layout',
(src.subtitleSidebar as { layout?: unknown }).layout,
resolved.subtitleSidebar.layout,
'Expected "overlay" or "embedded".',
);
}
const pauseVideoOnHover = asBoolean(
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover,
);
if (pauseVideoOnHover !== undefined) {
resolved.subtitleSidebar.pauseVideoOnHover = pauseVideoOnHover;
} else if ((src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined) {
resolved.subtitleSidebar.pauseVideoOnHover = fallback.pauseVideoOnHover;
warn(
'subtitleSidebar.pauseVideoOnHover',
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover,
resolved.subtitleSidebar.pauseVideoOnHover,
'Expected boolean.',
);
}
const autoScroll = asBoolean((src.subtitleSidebar as { autoScroll?: unknown }).autoScroll);
if (autoScroll !== undefined) {
resolved.subtitleSidebar.autoScroll = autoScroll;
} else if ((src.subtitleSidebar as { autoScroll?: unknown }).autoScroll !== undefined) {
resolved.subtitleSidebar.autoScroll = fallback.autoScroll;
warn(
'subtitleSidebar.autoScroll',
(src.subtitleSidebar as { autoScroll?: unknown }).autoScroll,
resolved.subtitleSidebar.autoScroll,
'Expected boolean.',
);
}
const toggleKey = asString((src.subtitleSidebar as { toggleKey?: unknown }).toggleKey);
if (toggleKey !== undefined) {
resolved.subtitleSidebar.toggleKey = toggleKey;
} else if ((src.subtitleSidebar as { toggleKey?: unknown }).toggleKey !== undefined) {
resolved.subtitleSidebar.toggleKey = fallback.toggleKey;
warn(
'subtitleSidebar.toggleKey',
(src.subtitleSidebar as { toggleKey?: unknown }).toggleKey,
resolved.subtitleSidebar.toggleKey,
'Expected string.',
);
}
const maxWidth = asNumber((src.subtitleSidebar as { maxWidth?: unknown }).maxWidth);
if (maxWidth !== undefined && maxWidth > 0) {
resolved.subtitleSidebar.maxWidth = Math.floor(maxWidth);
} else if ((src.subtitleSidebar as { maxWidth?: unknown }).maxWidth !== undefined) {
resolved.subtitleSidebar.maxWidth = fallback.maxWidth;
warn(
'subtitleSidebar.maxWidth',
(src.subtitleSidebar as { maxWidth?: unknown }).maxWidth,
resolved.subtitleSidebar.maxWidth,
'Expected positive number.',
);
}
const opacity = asNumber((src.subtitleSidebar as { opacity?: unknown }).opacity);
if (opacity !== undefined && opacity >= 0 && opacity <= 1) {
resolved.subtitleSidebar.opacity = opacity;
} else if ((src.subtitleSidebar as { opacity?: unknown }).opacity !== undefined) {
resolved.subtitleSidebar.opacity = fallback.opacity;
warn(
'subtitleSidebar.opacity',
(src.subtitleSidebar as { opacity?: unknown }).opacity,
resolved.subtitleSidebar.opacity,
'Expected number between 0 and 1.',
);
}
const hexColorFields = ['textColor', 'timestampColor', 'activeLineColor'] as const;
for (const field of hexColorFields) {
const value = asColor((src.subtitleSidebar as Record<string, unknown>)[field]);
if (value !== undefined) {
resolved.subtitleSidebar[field] = value;
} else if ((src.subtitleSidebar as Record<string, unknown>)[field] !== undefined) {
resolved.subtitleSidebar[field] = fallback[field];
warn(
`subtitleSidebar.${field}`,
(src.subtitleSidebar as Record<string, unknown>)[field],
resolved.subtitleSidebar[field],
'Expected hex color.',
);
}
}
const cssColorFields = [
'backgroundColor',
'activeLineBackgroundColor',
'hoverLineBackgroundColor',
] as const;
for (const field of cssColorFields) {
const value = asCssColor((src.subtitleSidebar as Record<string, unknown>)[field]);
if (value !== undefined) {
resolved.subtitleSidebar[field] = value;
} else if ((src.subtitleSidebar as Record<string, unknown>)[field] !== undefined) {
resolved.subtitleSidebar[field] = fallback[field];
warn(
`subtitleSidebar.${field}`,
(src.subtitleSidebar as Record<string, unknown>)[field],
resolved.subtitleSidebar[field],
'Expected valid CSS color.',
);
}
}
const fontFamily = asString((src.subtitleSidebar as { fontFamily?: unknown }).fontFamily);
if (fontFamily !== undefined && fontFamily.trim().length > 0) {
resolved.subtitleSidebar.fontFamily = fontFamily.trim();
} else if ((src.subtitleSidebar as { fontFamily?: unknown }).fontFamily !== undefined) {
resolved.subtitleSidebar.fontFamily = fallback.fontFamily;
warn(
'subtitleSidebar.fontFamily',
(src.subtitleSidebar as { fontFamily?: unknown }).fontFamily,
resolved.subtitleSidebar.fontFamily,
'Expected non-empty string.',
);
}
const fontSize = asNumber((src.subtitleSidebar as { fontSize?: unknown }).fontSize);
if (fontSize !== undefined && fontSize > 0) {
resolved.subtitleSidebar.fontSize = fontSize;
} else if ((src.subtitleSidebar as { fontSize?: unknown }).fontSize !== undefined) {
resolved.subtitleSidebar.fontSize = fallback.fontSize;
warn(
'subtitleSidebar.fontSize',
(src.subtitleSidebar as { fontSize?: unknown }).fontSize,
resolved.subtitleSidebar.fontSize,
'Expected positive number.',
);
}
}
}

View File

@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createResolveContext } from './context';
import { applySubtitleDomainConfig } from './subtitle-domains';
test('subtitleSidebar resolves valid values and preserves dedicated defaults', () => {
const { context } = createResolveContext({
subtitleSidebar: {
enabled: true,
autoOpen: true,
layout: 'embedded',
toggleKey: 'KeyB',
pauseVideoOnHover: true,
autoScroll: false,
maxWidth: 540,
opacity: 0.72,
backgroundColor: 'rgba(36, 39, 58, 0.72)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.enabled, true);
assert.equal(context.resolved.subtitleSidebar.autoOpen, true);
assert.equal(context.resolved.subtitleSidebar.layout, 'embedded');
assert.equal(context.resolved.subtitleSidebar.toggleKey, 'KeyB');
assert.equal(context.resolved.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(context.resolved.subtitleSidebar.autoScroll, false);
assert.equal(context.resolved.subtitleSidebar.maxWidth, 540);
assert.equal(context.resolved.subtitleSidebar.opacity, 0.72);
assert.equal(context.resolved.subtitleSidebar.fontFamily, '"Iosevka Aile", sans-serif');
assert.equal(context.resolved.subtitleSidebar.fontSize, 17);
});
test('subtitleSidebar accepts zero opacity', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
opacity: 0,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.opacity, 0);
assert.equal(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), false);
});
test('subtitleSidebar falls back and warns on invalid values', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
enabled: 'yes' as never,
autoOpen: 'yes' as never,
layout: 'floating' as never,
maxWidth: -1,
opacity: 5,
fontSize: 0,
textColor: 'blue',
backgroundColor: 'not-a-color',
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.enabled, false);
assert.equal(context.resolved.subtitleSidebar.autoOpen, false);
assert.equal(context.resolved.subtitleSidebar.layout, 'overlay');
assert.equal(context.resolved.subtitleSidebar.maxWidth, 420);
assert.equal(context.resolved.subtitleSidebar.opacity, 0.95);
assert.equal(context.resolved.subtitleSidebar.fontSize, 16);
assert.equal(context.resolved.subtitleSidebar.textColor, '#cad3f5');
assert.equal(context.resolved.subtitleSidebar.backgroundColor, 'rgba(73, 77, 100, 0.9)');
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.autoOpen'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.layout'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.maxWidth'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.fontSize'));
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.textColor'));
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleSidebar.backgroundColor' &&
warning.message === 'Expected valid CSS color.',
),
);
});