mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-22 12:11:27 -07:00
feat(subtitle-sidebar): add sidebar config surface (#28)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/config/resolve/subtitle-sidebar.test.ts
Normal file
93
src/config/resolve/subtitle-sidebar.test.ts
Normal 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.',
|
||||
),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user