diff --git a/changes/2026-03-20-subtitle-sidebar-stability.md b/changes/2026-03-20-subtitle-sidebar-stability.md new file mode 100644 index 0000000..8c35905 --- /dev/null +++ b/changes/2026-03-20-subtitle-sidebar-stability.md @@ -0,0 +1,5 @@ +type: fixed +area: overlay + +- Kept subtitle sidebar cue tracking stable across transitions by avoiding cue-line regression on subtitle timing edge cases and stale text updates. +- Improved sidebar config by documenting and exposing layout mode and typography options (`layout`, `fontFamily`, `fontSize`) in the generated documentation flow. diff --git a/config.example.jsonc b/config.example.jsonc index bf713e6..6fbe805 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -284,6 +284,29 @@ } // Secondary setting. }, // Primary and secondary subtitle styling. + // ========================================== + // Subtitle Sidebar + // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. + // Hot-reload: subtitle sidebar changes apply live without restarting SubMiner. + // ========================================== + "subtitleSidebar": { + "enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false + "layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. + "toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed. + "pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false + "autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false + "maxWidth": 420, // Maximum sidebar width in CSS pixels. + "opacity": 0.95, // Base opacity applied to the sidebar shell. + "backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell. + "textColor": "#cad3f5", // Default cue text color in the subtitle sidebar. + "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text. + "fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels. + "timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar. + "activeLineColor": "#f5bde6", // Text color for the active subtitle cue. + "activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue. + "hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues. + }, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. + // ========================================== // Shared AI Provider // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 5bde096..be38853 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -59,6 +59,7 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while Hot-reloadable fields: - `subtitleStyle` +- `subtitleSidebar` - `keybindings` - `shortcuts` - `secondarySub.defaultMode` @@ -88,6 +89,7 @@ The configuration file includes several main sections: **Subtitle Display** - [**Subtitle Style**](#subtitle-style) - Appearance customization +- [**Subtitle Sidebar**](#subtitle-sidebar) - Parsed cue list sidebar modal - [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning - [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support @@ -337,6 +339,46 @@ Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, san **See `config.example.jsonc`** for the complete list of subtitle style configuration options. +### Subtitle Sidebar + +Configure the parsed-subtitle sidebar modal. + +```json +{ + "subtitleSidebar": { + "enabled": true, + "layout": "overlay", + "toggleKey": "Backslash", + "pauseVideoOnHover": false, + "autoScroll": true, + "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", + "fontSize": 16 + } +} +``` + +| Option | Values | Description | +| --------------------------- | ---------------- | -------------------------------------------------------------------------------- | +| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) | +| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | +| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | +| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list | +| `autoScroll` | boolean | Keep the active cue in view while playback advances | +| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | +| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.78`) | +| `backgroundColor` | string | Sidebar shell background color | +| `textColor` | hex color | Default cue text color | +| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | +| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) | +| `timestampColor` | hex color | Cue timestamp color | +| `activeLineColor` | hex color | Active cue text color | +| `activeLineBackgroundColor` | string | Active cue background color | +| `hoverLineBackgroundColor` | string | Hovered cue background color | + +The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog. + +`embedded` layout is intended to act like a split-pane view: it reserves player space with a right-side video margin and keeps interaction in both the player area and sidebar. If you see unexpected offset behavior in your environment, switch back to `overlay` to isolate sidebar placement. + `jlptColors` keys are: | Key | Default | Description | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index bf713e6..6fbe805 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -284,6 +284,29 @@ } // Secondary setting. }, // Primary and secondary subtitle styling. + // ========================================== + // Subtitle Sidebar + // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. + // Hot-reload: subtitle sidebar changes apply live without restarting SubMiner. + // ========================================== + "subtitleSidebar": { + "enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false + "layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. + "toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed. + "pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false + "autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false + "maxWidth": 420, // Maximum sidebar width in CSS pixels. + "opacity": 0.95, // Base opacity applied to the sidebar shell. + "backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell. + "textColor": "#cad3f5", // Default cue text color in the subtitle sidebar. + "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text. + "fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels. + "timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar. + "activeLineColor": "#f5bde6", // Text color for the active subtitle cue. + "activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue. + "hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues. + }, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. + // ========================================== // Shared AI Provider // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 3d4f9b1..503aa17 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -68,10 +68,13 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | | `` ` `` | Toggle stats overlay | `stats.toggleKey` | The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. +The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. + ## Controller Shortcuts These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration. @@ -133,4 +136,4 @@ The `keybindings` array overrides or extends the overlay's built-in key handling } ``` -Both `shortcuts` and `keybindings` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner. +Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner. diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 396bada..59c4f99 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -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, diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index 6798d3d..717f1a9 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -1,6 +1,6 @@ import { ResolvedConfig } from '../../types'; -export const SUBTITLE_DEFAULT_CONFIG: Pick = { +export const SUBTITLE_DEFAULT_CONFIG: Pick = { subtitleStyle: { enableJlpt: false, preserveLineBreaks: false, @@ -57,4 +57,21 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick = { fontStyle: 'normal', }, }, + subtitleSidebar: { + enabled: 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)', + }, }; diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index 72c3ec4..e5774c8 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -110,5 +110,95 @@ 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.layout', + kind: 'string', + 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.', + }, ]; } diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index c2ae9d8..d988402 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -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[] = [ diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 1d906c5..25e12c6 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -418,4 +418,167 @@ 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 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)[field]); + if (value !== undefined) { + resolved.subtitleSidebar[field] = value; + } else if ((src.subtitleSidebar as Record)[field] !== undefined) { + resolved.subtitleSidebar[field] = fallback[field]; + warn( + `subtitleSidebar.${field}`, + (src.subtitleSidebar as Record)[field], + resolved.subtitleSidebar[field], + 'Expected hex color.', + ); + } + } + + const cssColorFields = [ + 'backgroundColor', + 'activeLineBackgroundColor', + 'hoverLineBackgroundColor', + ] as const; + for (const field of cssColorFields) { + const value = asString((src.subtitleSidebar as Record)[field]); + if (value !== undefined && value.trim().length > 0) { + resolved.subtitleSidebar[field] = value.trim(); + } else if ((src.subtitleSidebar as Record)[field] !== undefined) { + resolved.subtitleSidebar[field] = fallback[field]; + warn( + `subtitleSidebar.${field}`, + (src.subtitleSidebar as Record)[field], + resolved.subtitleSidebar[field], + 'Expected string.', + ); + } + } + + 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.', + ); + } + } } diff --git a/src/config/resolve/subtitle-sidebar.test.ts b/src/config/resolve/subtitle-sidebar.test.ts new file mode 100644 index 0000000..d8e4db6 --- /dev/null +++ b/src/config/resolve/subtitle-sidebar.test.ts @@ -0,0 +1,66 @@ +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, + 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.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 falls back and warns on invalid values', () => { + const { context, warnings } = createResolveContext({ + subtitleSidebar: { + enabled: 'yes' as never, + layout: 'floating' as never, + maxWidth: -1, + opacity: 5, + fontSize: 0, + textColor: 'blue', + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleSidebar.enabled, 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.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.enabled')); + 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')); +}); diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 3e751f2..5a405f3 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -42,6 +42,9 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo if (!isEqual(prev.shortcuts, next.shortcuts)) { hotReloadFields.push('shortcuts'); } + if (!isEqual(prev.subtitleSidebar, next.subtitleSidebar)) { + hotReloadFields.push('subtitleSidebar'); + } if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) { hotReloadFields.push('secondarySub.defaultMode'); } @@ -55,7 +58,7 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo ]); for (const key of keys) { - if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') { + if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts' || key === 'subtitleSidebar') { continue; }