2 Commits

40 changed files with 2459 additions and 61 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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.

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,21 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
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)',
},
};

View File

@@ -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.',
},
];
}

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

@@ -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<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 = asString((src.subtitleSidebar as Record<string, unknown>)[field]);
if (value !== undefined && value.trim().length > 0) {
resolved.subtitleSidebar[field] = value.trim();
} 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 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.',
);
}
}
}

View File

@@ -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'));
});

View File

@@ -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;
}

View File

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -77,6 +78,30 @@ function createControllerConfigFixture() {
};
}
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
cues: [],
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
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)',
},
};
}
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
return {
onOverlayModalClosed: () => {},
@@ -88,6 +113,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
@@ -173,6 +199,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => true,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
@@ -269,6 +296,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
cycles.push({ id, direction });
return { ok: true };
},
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
@@ -320,6 +348,24 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
);
});
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const snapshot = createSubtitleSidebarSnapshotFixture();
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
snapshot.config.enabled = true;
registerIpcHandlers(
createRegisterIpcDeps({
getSubtitleSidebarSnapshot: async () => snapshot,
}),
registrar,
);
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
assert.ok(handler);
assert.deepEqual(await handler!({}), snapshot);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -530,6 +576,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
@@ -596,6 +643,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
@@ -667,6 +715,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,

View File

@@ -6,6 +6,7 @@ import type {
ResolvedControllerConfig,
RuntimeOptionId,
RuntimeOptionValue,
SubtitleSidebarSnapshot,
SubtitlePosition,
SubsyncManualRunRequest,
SubsyncResult,
@@ -37,6 +38,7 @@ export interface IpcServiceDeps {
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
@@ -143,6 +145,7 @@ export interface IpcDepsRuntimeOptions {
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
@@ -190,6 +193,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle,
@@ -321,6 +325,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getCurrentSubtitleAss();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarSnapshot, async () => {
if (!deps.getSubtitleSidebarSnapshot) {
throw new Error('Subtitle sidebar snapshot is unavailable.');
}
return await deps.getSubtitleSidebarSnapshot();
});
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
return deps.getPlaybackPaused();
});

View File

@@ -183,7 +183,13 @@ export function parseSubtitleCues(content: string, filename: string): SubtitleCu
cues = parseAssCues(content);
break;
default:
return [];
cues = [];
}
if (cues.length === 0) {
const assCues = parseAssCues(content);
const srtCues = parseSrtCues(content);
cues = assCues.length >= srtCues.length ? assCues : srtCues;
}
cues.sort((a, b) => a.startTime - b.startTime);

View File

@@ -438,10 +438,11 @@ import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import {
getActiveExternalSubtitleSource,
buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath,
} from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import { codecToExtension } from './subsync/utils';
if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -1142,15 +1143,23 @@ function maybeSignalPluginAutoplayReady(
let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
return {
...payload,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
}
function emitSubtitlePayload(payload: SubtitleData): void {
appState.currentSubtitleData = payload;
broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, {
const timedPayload = withCurrentSubtitleTiming(payload);
appState.currentSubtitleData = timedPayload;
broadcastToOverlayWindows('subtitle:set', timedPayload);
subtitleWsService.broadcast(timedPayload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
annotationSubtitleWsService.broadcast(payload, {
annotationSubtitleWsService.broadcast(timedPayload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
@@ -1200,6 +1209,10 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
appState.activeParsedSubtitleCues = cues ?? [];
appState.activeParsedSubtitleSource = sourceKey;
},
});
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
@@ -1209,19 +1222,40 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
}
try {
const [trackListRaw, sidRaw] = await Promise.all([
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'),
client.requestProperty('sid'),
]);
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
if (!externalFilename) {
client.requestProperty('path'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
subtitlePrefetchInitController.cancelPendingInit();
return;
}
await subtitlePrefetchInitController.initSubtitlePrefetch(
externalFilename,
lastObservedTimePos,
const resolvedSource = await resolveActiveSubtitleSidebarSource(
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
);
if (!resolvedSource) {
subtitlePrefetchInitController.cancelPendingInit();
return;
}
try {
await subtitlePrefetchInitController.initSubtitlePrefetch(
resolvedSource.path,
lastObservedTimePos,
resolvedSource.sourceKey,
);
} finally {
await resolvedSource.cleanup?.();
}
} catch {
// Track list query failed; skip subtitle prefetch refresh.
}
@@ -2965,6 +2999,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
? {
text: appState.currentSubText,
tokens: null,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
}
: null),
() => ({
@@ -2983,6 +3019,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
? {
text: appState.currentSubText,
tokens: null,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
}
: null),
() => ({
@@ -3257,6 +3295,9 @@ const {
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
},
resetSubtitleSidebarEmbeddedLayout: () => {
resetSubtitleSidebarEmbeddedLayoutRuntime();
},
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => {
resetAnilistMediaTracking(mediaKey);
@@ -3475,6 +3516,11 @@ function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
}
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]);
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
}
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
updateMpvSubtitleRenderMetricsHandler(patch);
}
@@ -3919,6 +3965,168 @@ async function loadSubtitleSourceText(source: string): Promise<string> {
return fs.promises.readFile(filePath, 'utf8');
}
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
selected?: unknown;
external?: unknown;
codec?: unknown;
'ff-index'?: unknown;
'external-filename'?: unknown;
};
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function getActiveSubtitleTrack(
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
): MpvSubtitleTrackLike | null {
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
const track = currentTrackRaw as MpvSubtitleTrackLike;
if (track.type === undefined || track.type === 'sub') {
return track;
}
}
const sid = parseTrackId(sidRaw);
if (!Array.isArray(trackListRaw)) {
return null;
}
const bySid =
sid === null
? null
: ((trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && parseTrackId(track.id) === sid;
}) as MpvSubtitleTrackLike | undefined) ?? null);
if (bySid) {
return bySid;
}
return (
(trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const track = entry as MpvSubtitleTrackLike;
return track.type === 'sub' && track.selected === true;
}) as MpvSubtitleTrackLike | undefined) ?? null
);
}
function buildFfmpegSubtitleExtractionArgs(
videoPath: string,
ffIndex: number,
outputPath: string,
): string[] {
return [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
'-map',
`0:${ffIndex}`,
'-f',
path.extname(outputPath).slice(1),
outputPath,
];
}
async function extractInternalSubtitleTrackToTempFile(
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
const ffIndex = typeof track['ff-index'] === 'number' ? track['ff-index'] : null;
const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined);
if (ffIndex === null || extension === null) {
return null;
}
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
await new Promise<void>((resolve, reject) => {
const child = spawn(ffmpegPath, buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath));
let stderr = '';
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
});
});
return {
path: outputPath,
cleanup: async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
},
};
}
async function resolveActiveSubtitleSidebarSource(
currentExternalFilenameRaw: unknown,
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
videoPath: string,
): Promise<{ path: string; sourceKey: string; cleanup?: () => Promise<void> } | null> {
const currentExternalFilename =
typeof currentExternalFilenameRaw === 'string' ? currentExternalFilenameRaw.trim() : '';
if (currentExternalFilename) {
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
}
const track = getActiveSubtitleTrack(currentTrackRaw, trackListRaw, sidRaw);
if (!track) {
return null;
}
const externalFilename =
typeof track['external-filename'] === 'string' ? track['external-filename'].trim() : '';
if (externalFilename) {
return { path: externalFilename, sourceKey: externalFilename };
}
const ffmpegPath = getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg';
const extracted = await extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track);
if (!extracted) {
return null;
}
return {
...extracted,
sourceKey: buildSubtitleSidebarSourceKey(videoPath, track, extracted.path),
};
}
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () => appState.mpvClient,
loadSubtitleSourceText,
@@ -3976,9 +4184,102 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
const currentSubtitle = {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
const currentTimeSec = appState.mpvClient?.currentTimePos ?? null;
const config = getResolvedConfig().subtitleSidebar;
const client = appState.mpvClient;
if (!client?.connected) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
const [
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPathRaw,
] = await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('path'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
const resolvedSource = await resolveActiveSubtitleSidebarSource(
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
);
if (!resolvedSource) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
if (
appState.activeParsedSubtitleCues.length > 0 &&
appState.activeParsedSubtitleSource === resolvedSource.sourceKey
) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
const content = await loadSubtitleSourceText(resolvedSource.path);
const cues = parseSubtitleCues(content, resolvedSource.path);
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
return {
cues,
currentTimeSec,
currentSubtitle,
config,
};
} finally {
await resolvedSource.cleanup?.();
}
} catch {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
},
getPlaybackPaused: () => appState.playbackPaused,
getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => {

View File

@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
@@ -212,6 +213,7 @@ export function createMainIpcRuntimeServiceDeps(
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle,

View File

@@ -36,6 +36,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar,
secondarySubMode: config.secondarySub.defaultMode,
};
}

View File

@@ -82,6 +82,7 @@ test('media path change handler reports stop for empty path and probes media key
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'show:1',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -97,6 +98,7 @@ test('media path change handler reports stop for empty path and probes media key
assert.deepEqual(calls, [
'flush-playback',
'path:',
'reset-sidebar-layout',
'stopped',
'restore-mpv-sub',
'reset:show:1',
@@ -113,6 +115,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -128,35 +131,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
assert.deepEqual(calls, [
'path:/tmp/video.mkv',
'reset:null',
'sync',
'dict-sync',
'autoplay:/tmp/video.mkv',
'presence',
]);
});
test('media path change handler ignores playback flush for non-empty path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '/tmp/video.mkv' });
assert.ok(!calls.includes('flush-playback'));
assert.deepEqual(calls, [
'path:/tmp/video.mkv',
'reset-sidebar-layout',
'reset:null',
'sync',
'dict-sync',

View File

@@ -46,6 +46,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
updateCurrentMediaPath: (path: string) => void;
reportJellyfinRemoteStopped: () => void;
restoreMpvSubVisibility: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -62,6 +63,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
}
deps.updateCurrentMediaPath(normalizedPath);
deps.resetSubtitleSidebarEmbeddedLayout();
if (!normalizedPath) {
deps.reportJellyfinRemoteStopped();
deps.restoreMpvSubVisibility();

View File

@@ -9,6 +9,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
@@ -67,6 +68,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
},
});
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
@@ -76,6 +78,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('broadcast-sub:line'));
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));

View File

@@ -21,6 +21,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
@@ -83,6 +84,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
isMpvConnected: () => deps.isMpvConnected(),
quitApp: () => deps.quitApp(),
});
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
if (connected) {
deps.resetSubtitleSidebarEmbeddedLayout();
}
handleMpvConnectionChange({ connected });
};
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
deps.recordImmersionSubtitleLine(text, start, end),
@@ -110,6 +117,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
@@ -150,7 +158,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
});
createBindMpvClientEventHandlers({
onConnectionChange: handleMpvConnectionChange,
onConnectionChange: handleMpvConnectionChangeWithSidebarReset,
onSubtitleChange: handleMpvSubtitleChange,
onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,

View File

@@ -47,6 +47,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.broadcastSecondarySubtitle('sec');
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key');
@@ -112,6 +114,5 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('immersion-time:12.25'));
assert.ok(calls.includes('immersion-time:18.75'));
assert.ok(calls.includes('reset-sidebar-layout'));
});

View File

@@ -50,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
resetSubtitleSidebarEmbeddedLayout?: () => void;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string) => void;
@@ -146,6 +147,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),

View File

@@ -112,3 +112,90 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
assert.equal(currentService, null);
assert.deepEqual(started, []);
});
test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const cueUpdates: Array<SubtitleCue[] | null> = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: () => [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
cueUpdates.push(cues);
},
});
const initPromise = controller.initSubtitlePrefetch('episode.ass', 12);
deferred.resolve('content');
await initPromise;
controller.cancelPendingInit();
assert.deepEqual(cueUpdates, [
[
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
null,
]);
});
test('subtitle prefetch init publishes the provided stable source key instead of the load path', async () => {
const deferred = createDeferred<string>();
let currentService: SubtitlePrefetchService | null = null;
const sourceUpdates: Array<string | null> = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => await deferred.promise,
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
createSubtitlePrefetchService: () => ({
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (_cues, source) => {
sourceUpdates.push(source);
},
});
const initPromise = controller.initSubtitlePrefetch(
'/tmp/subminer-sidebar-123/track_7.ass',
12,
'internal:/media/episode01.mkv:track:3:ff:7',
);
deferred.resolve('content');
await initPromise;
assert.deepEqual(sourceUpdates, ['internal:/media/episode01.mkv:track:3:ff:7']);
});

View File

@@ -16,11 +16,16 @@ export interface SubtitlePrefetchInitControllerDeps {
isCacheFull: () => boolean;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
}
export interface SubtitlePrefetchInitController {
cancelPendingInit: () => void;
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
initSubtitlePrefetch: (
sourcePath: string,
currentTimePos: number,
sourceKey?: string,
) => Promise<void>;
}
export function createSubtitlePrefetchInitController(
@@ -32,24 +37,29 @@ export function createSubtitlePrefetchInitController(
initRevision += 1;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
deps.onParsedSubtitleCuesChanged?.(null, null);
};
const initSubtitlePrefetch = async (
externalFilename: string,
sourcePath: string,
currentTimePos: number,
sourceKey = sourcePath,
): Promise<void> => {
const revision = ++initRevision;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
try {
const content = await deps.loadSubtitleSourceText(externalFilename);
const content = await deps.loadSubtitleSourceText(sourcePath);
if (revision !== initRevision) {
return;
}
const cues = deps.parseSubtitleCues(content, externalFilename);
const cues = deps.parseSubtitleCues(content, sourcePath);
if (revision !== initRevision || cues.length === 0) {
if (revision === initRevision) {
deps.onParsedSubtitleCuesChanged?.(null, null);
}
return;
}
@@ -65,9 +75,10 @@ export function createSubtitlePrefetchInitController(
}
deps.setCurrentService(nextService);
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos);
deps.logInfo(
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`,
);
} catch (error) {
if (revision === initRevision) {

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSubtitleSidebarSourceKey,
getActiveExternalSubtitleSource,
resolveSubtitleSourcePath,
} from './subtitle-prefetch-source';
@@ -48,3 +49,29 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U
assert.equal(resolveSubtitleSourcePath(source), source);
});
test('buildSubtitleSidebarSourceKey uses a stable identifier for internal subtitle tracks', () => {
const firstKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: 3,
'ff-index': 7,
title: 'English',
lang: 'eng',
codec: 'ass',
});
const secondKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
id: 3,
'ff-index': 7,
title: 'English',
lang: 'eng',
codec: 'ass',
});
assert.equal(firstKey, secondKey);
assert.equal(firstKey, 'internal:/media/episode01.mkv:track:3:ff:7');
});
test('buildSubtitleSidebarSourceKey falls back to source path when no track metadata is available', () => {
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', null, '/tmp/subtitle.ass');
assert.equal(key, '/tmp/subtitle.ass');
});

View File

@@ -1,5 +1,16 @@
import { fileURLToPath } from 'node:url';
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
export function getActiveExternalSubtitleSource(
trackListRaw: unknown,
sidRaw: unknown,
@@ -19,7 +30,7 @@ export function getActiveExternalSubtitleSource(
return false;
}
const track = entry as Record<string, unknown>;
return track.type === 'sub' && track.id === sid && track.external === true;
return track.type === 'sub' && track.id === sid;
}) as Record<string, unknown> | undefined;
const externalFilename =
@@ -40,3 +51,21 @@ export function resolveSubtitleSourcePath(source: string): string {
return source;
}
}
export function buildSubtitleSidebarSourceKey(
videoPath: string,
track: unknown,
fallbackSourcePath?: string,
): string {
const normalizedVideoPath = videoPath.trim();
if (track && typeof track === 'object' && normalizedVideoPath) {
const subtitleTrack = track as Record<string, unknown>;
const trackId = parseTrackId(subtitleTrack.id);
const ffIndex = parseTrackId(subtitleTrack['ff-index']);
if (trackId !== null || ffIndex !== null) {
return `internal:${normalizedVideoPath}:track:${trackId ?? 'unknown'}:ff:${ffIndex ?? 'unknown'}`;
}
}
return fallbackSourcePath ?? normalizedVideoPath;
}

View File

@@ -5,6 +5,7 @@ import type {
MpvSubtitleRenderMetrics,
SecondarySubMode,
SubtitleData,
SubtitleCue,
SubtitlePosition,
KikuFieldGroupingChoice,
JlptLevel,
@@ -158,6 +159,8 @@ export interface AppState {
currentSubText: string;
currentSubAssText: string;
currentSubtitleData: SubtitleData | null;
activeParsedSubtitleCues: SubtitleCue[];
activeParsedSubtitleSource: string | null;
windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
@@ -238,6 +241,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentSubText: '',
currentSubAssText: '',
currentSubtitleData: null,
activeParsedSubtitleCues: [],
activeParsedSubtitleSource: null,
windowTracker: null,
subtitlePosition: null,
currentMediaPath: null,

View File

@@ -169,6 +169,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getPlaybackPaused: (): Promise<boolean | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
onSubtitleAss: (callback: (assText: string) => void) => {

View File

@@ -27,6 +27,7 @@ export function createKeyboardHandlers(
getPlaybackPaused: () => Promise<boolean | null>;
openControllerSelectModal: () => void;
openControllerDebugModal: () => void;
toggleSubtitleSidebarModal?: () => void;
},
) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
@@ -181,6 +182,26 @@ export function createKeyboardHandlers(
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
}
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
const toggleKey = ctx.state.subtitleSidebarToggleKey;
if (!toggleKey) return false;
const isBackslashConfigured = toggleKey === 'Backslash' || toggleKey === '\\';
const isBackslashLikeCode = ['Backslash', 'IntlBackslash', 'IntlYen'].includes(e.code);
const keyMatches =
toggleKey === e.code ||
(isBackslashConfigured && isBackslashLikeCode) ||
(isBackslashConfigured && e.key === '\\') ||
(toggleKey.length === 1 && e.key === toggleKey);
return (
keyMatches &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.repeat
);
}
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
return (
e.code === ctx.state.statsToggleKey &&
@@ -838,6 +859,12 @@ export function createKeyboardHandlers(
return;
}
if (isSubtitleSidebarToggle(e)) {
e.preventDefault();
options.toggleSubtitleSidebarModal?.();
return;
}
if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e)

View File

@@ -256,6 +256,18 @@
</div>
</div>
</div>
<div id="subtitleSidebarModal" class="modal hidden subtitle-sidebar-modal" aria-hidden="true">
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
<div class="modal-header">
<div class="modal-title">Subtitle Sidebar</div>
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body subtitle-sidebar-body">
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
</div>
</div>
</div>
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
<div class="modal-content session-help-content">
<div class="modal-header">

View File

@@ -0,0 +1,622 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import { createRendererState } from '../state.js';
import { createSubtitleSidebarModal, findActiveSubtitleCueIndex } from './subtitle-sidebar.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
function createCueRow() {
return {
className: '',
classList: createClassList(),
dataset: {} as Record<string, string>,
textContent: '',
offsetTop: 0,
clientHeight: 40,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
scrollIntoViewCalls: [] as ScrollIntoViewOptions[],
scrollIntoView(options?: ScrollIntoViewOptions) {
this.scrollIntoViewCalls.push(options ?? {});
},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as ReturnType<typeof createCueRow>[],
appendChild(child: ReturnType<typeof createCueRow>) {
child.offsetTop = this.children.length * child.clientHeight;
this.children.push(child);
},
addEventListener: () => {},
scrollTop: 0,
clientHeight: 240,
scrollHeight: 480,
scrollToCalls: [] as ScrollToOptions[],
scrollTo(options?: ScrollToOptions) {
this.scrollToCalls.push(options ?? {});
},
};
}
test('findActiveSubtitleCueIndex prefers timing match before text fallback', () => {
const cues = [
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 3, endTime: 4, text: 'same' },
];
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: 3.1 }), 1);
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: null }), 0);
});
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
currentSubtitle: {
text: 'second',
startTime: 3,
endTime: 4,
},
config: {
enabled: true,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
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)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
} as unknown as ElectronAPI,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const cueList = createListStub();
const ctx = {
dom: {
overlay: { classList: overlayClassList },
subtitleSidebarModal: {
classList: modalClassList,
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
await modal.openSubtitleSidebarModal();
assert.equal(state.subtitleSidebarModalOpen, true);
assert.equal(modalClassList.contains('hidden'), false);
assert.equal(state.subtitleSidebarActiveCueIndex, 1);
assert.equal(cueList.children.length, 2);
assert.deepEqual(cueList.scrollToCalls[0], {
top: 0,
behavior: 'auto',
});
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timing', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
cues: [
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 3, endTime: 4, text: 'other' },
{ startTime: 10, endTime: 11, text: 'same' },
],
currentSubtitle: {
text: 'same',
startTime: 10,
endTime: 11,
},
currentTimeSec: 10.1,
config: {
enabled: true,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
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)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const cueList = createListStub();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
await modal.openSubtitleSidebarModal();
cueList.scrollToCalls.length = 0;
modal.handleSubtitleUpdated({
text: 'same',
startTime: null,
endTime: null,
tokens: [],
});
assert.equal(state.subtitleSidebarActiveCueIndex, 2);
assert.deepEqual(cueList.scrollToCalls, []);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar does not regress to previous cue on text-only transition update', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
{ startTime: 5, endTime: 6, text: 'third' },
],
currentSubtitle: {
text: 'third',
startTime: 5,
endTime: 6,
},
currentTimeSec: 5.1,
config: {
enabled: true,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
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)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const cueList = createListStub();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
await modal.openSubtitleSidebarModal();
cueList.scrollToCalls.length = 0;
modal.handleSubtitleUpdated({
text: 'second',
startTime: null,
endTime: null,
tokens: [],
});
assert.equal(state.subtitleSidebarActiveCueIndex, 2);
assert.deepEqual(cueList.scrollToCalls, []);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
layout: 'embedded',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 360,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
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)',
},
};
const rootStyleCalls: Array<[string, string]> = [];
const bodyClassList = createClassList();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: bodyClassList,
},
documentElement: {
style: {
setProperty: (name: string, value: string) => {
rootStyleCalls.push([name, value]);
},
},
},
},
});
try {
const state = createRendererState();
const cueList = createListStub();
const modalClassList = createClassList(['hidden']);
const contentClassList = createClassList();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: modalClassList,
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: contentClassList,
getBoundingClientRect: () => ({ width: 360 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
await modal.openSubtitleSidebarModal();
assert.ok(
mpvCommands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'video-margin-ratio-right' &&
command[2] === 0.3,
),
);
assert.ok(bodyClassList.contains('subtitle-sidebar-embedded-open'));
assert.ok(
rootStyleCalls.some(
([name, value]) => name === '--subtitle-sidebar-reserved-width' && value === '360px',
),
);
modal.closeSubtitleSidebarModal();
assert.deepEqual(mpvCommands.at(-2), ['set_property', 'video-margin-ratio-right', 0]);
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'video-pan-x', 0]);
assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false);
assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar resets embedded mpv margin on startup while closed', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
layout: 'embedded',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 360,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
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)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
await modal.refreshSubtitleSidebarSnapshot();
assert.deepEqual(mpvCommands, [
['set_property', 'video-margin-ratio-right', 0],
['set_property', 'video-pan-x', 0],
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});

View File

@@ -0,0 +1,461 @@
import type {
SubtitleCue,
SubtitleData,
SubtitleSidebarSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
const CLICK_SEEK_OFFSET_SEC = 0.08;
const SNAPSHOT_POLL_INTERVAL_MS = 80;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
const left = a[i]!;
const right = b[i]!;
if (
left.startTime !== right.startTime ||
left.endTime !== right.endTime ||
left.text !== right.text
) {
return false;
}
}
return true;
}
function normalizeCueText(text: string): string {
return text.replace(/\r\n/g, '\n').trim();
}
function formatCueTimestamp(seconds: number): string {
const totalSeconds = Math.max(0, Math.floor(seconds));
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
export function findActiveSubtitleCueIndex(
cues: SubtitleCue[],
current: { text: string; startTime?: number | null } | null,
currentTimeSec: number | null = null,
preferredCueIndex: number = -1,
): number {
if (cues.length === 0) {
return -1;
}
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
const activeOrUpcomingCue = cues.findIndex(
(cue) =>
cue.endTime > currentTimeSec &&
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
);
if (activeOrUpcomingCue >= 0) {
return activeOrUpcomingCue;
}
const nextCue = cues.findIndex((cue) => cue.endTime > currentTimeSec);
if (nextCue >= 0) {
return nextCue;
}
}
if (!current) {
return -1;
}
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
const timingMatch = cues.findIndex(
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
);
if (timingMatch >= 0) {
return timingMatch;
}
}
const normalizedText = normalizeCueText(current.text);
if (!normalizedText) {
return -1;
}
const matchingIndices: number[] = [];
for (const [index, cue] of cues.entries()) {
if (normalizeCueText(cue.text) === normalizedText) {
matchingIndices.push(index);
}
}
if (matchingIndices.length === 0) {
return -1;
}
const hasTiming =
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
if (preferredCueIndex >= 0) {
if (!hasTiming && currentTimeSec === null) {
const forwardMatches = matchingIndices.filter((index) => index >= preferredCueIndex);
if (forwardMatches.length > 0) {
return forwardMatches[0]!;
}
return preferredCueIndex;
}
let nearestIndex = matchingIndices[0]!;
let nearestDistance = Math.abs(nearestIndex - preferredCueIndex);
for (const matchIndex of matchingIndices) {
const distance = Math.abs(matchIndex - preferredCueIndex);
if (distance < nearestDistance) {
nearestIndex = matchIndex;
nearestDistance = distance;
}
}
return nearestIndex;
}
return matchingIndices[0]!;
}
export function createSubtitleSidebarModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
},
) {
let snapshotPollInterval: ReturnType<typeof setInterval> | null = null;
let lastAppliedVideoMarginRatio: number | null = null;
function setStatus(message: string): void {
ctx.dom.subtitleSidebarStatus.textContent = message;
}
function getReservedSidebarWidthPx(): number {
const config = ctx.state.subtitleSidebarConfig;
if (!config || config.layout !== 'embedded' || !ctx.state.subtitleSidebarModalOpen) {
return 0;
}
const measuredWidth = ctx.dom.subtitleSidebarContent.getBoundingClientRect().width;
if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
return measuredWidth;
}
return Math.max(EMBEDDED_SIDEBAR_MIN_WIDTH_PX, config.maxWidth);
}
function syncEmbeddedSidebarLayout(): void {
const config = ctx.state.subtitleSidebarConfig;
const reservedWidthPx = getReservedSidebarWidthPx();
const embedded = Boolean(config && config.layout === 'embedded' && reservedWidthPx > 0);
if (embedded) {
ctx.dom.subtitleSidebarContent.classList.add('subtitle-sidebar-content-embedded');
ctx.dom.subtitleSidebarModal.classList.add('subtitle-sidebar-modal-embedded');
document.body.classList.add('subtitle-sidebar-embedded-open');
} else {
ctx.dom.subtitleSidebarContent.classList.remove('subtitle-sidebar-content-embedded');
ctx.dom.subtitleSidebarModal.classList.remove('subtitle-sidebar-modal-embedded');
document.body.classList.remove('subtitle-sidebar-embedded-open');
}
document.documentElement.style.setProperty(
'--subtitle-sidebar-reserved-width',
`${Math.max(0, Math.round(reservedWidthPx))}px`,
);
const viewportWidth = window.innerWidth;
const ratio =
embedded && Number.isFinite(viewportWidth) && viewportWidth > 0
? Math.min(EMBEDDED_SIDEBAR_MAX_RATIO, reservedWidthPx / viewportWidth)
: 0;
if (
lastAppliedVideoMarginRatio !== null &&
Math.abs(ratio - lastAppliedVideoMarginRatio) < 0.0001
) {
return;
}
lastAppliedVideoMarginRatio = ratio;
window.electronAPI.sendMpvCommand([
'set_property',
'video-margin-ratio-right',
Number(ratio.toFixed(4)),
]);
if (ratio === 0) {
window.electronAPI.sendMpvCommand(['set_property', 'video-pan-x', 0]);
}
}
function applyConfig(snapshot: SubtitleSidebarSnapshot): void {
ctx.state.subtitleSidebarConfig = snapshot.config;
ctx.state.subtitleSidebarToggleKey = snapshot.config.toggleKey;
ctx.state.subtitleSidebarPauseVideoOnHover = snapshot.config.pauseVideoOnHover;
ctx.state.subtitleSidebarAutoScroll = snapshot.config.autoScroll;
const style = ctx.dom.subtitleSidebarModal.style;
style.setProperty('--subtitle-sidebar-max-width', `${snapshot.config.maxWidth}px`);
style.setProperty('--subtitle-sidebar-opacity', String(snapshot.config.opacity));
style.setProperty('--subtitle-sidebar-background-color', snapshot.config.backgroundColor);
style.setProperty('--subtitle-sidebar-text-color', snapshot.config.textColor);
style.setProperty('--subtitle-sidebar-font-family', snapshot.config.fontFamily);
style.setProperty('--subtitle-sidebar-font-size', `${snapshot.config.fontSize}px`);
style.setProperty('--subtitle-sidebar-timestamp-color', snapshot.config.timestampColor);
style.setProperty('--subtitle-sidebar-active-line-color', snapshot.config.activeLineColor);
style.setProperty(
'--subtitle-sidebar-active-background-color',
snapshot.config.activeLineBackgroundColor,
);
style.setProperty(
'--subtitle-sidebar-hover-background-color',
snapshot.config.hoverLineBackgroundColor,
);
}
function seekToCue(cue: SubtitleCue): void {
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
window.electronAPI.sendMpvCommand([
'seek',
Math.max(cue.startTime, targetTime),
'absolute+exact',
]);
}
function maybeAutoScrollActiveCue(
previousActiveCueIndex: number,
behavior: ScrollBehavior = 'smooth',
force = false,
): void {
if (
!ctx.state.subtitleSidebarAutoScroll ||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
) {
return;
}
const list = ctx.dom.subtitleSidebarList;
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
if (!active) {
return;
}
const targetScrollTop =
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
list.scrollTo({
top: Math.max(0, targetScrollTop),
behavior,
});
}
function renderCueList(): void {
ctx.dom.subtitleSidebarList.innerHTML = '';
for (const [index, cue] of ctx.state.subtitleSidebarCues.entries()) {
const row = document.createElement('li');
row.className = 'subtitle-sidebar-item';
row.classList.toggle('active', index === ctx.state.subtitleSidebarActiveCueIndex);
row.dataset.index = String(index);
const timestamp = document.createElement('div');
timestamp.className = 'subtitle-sidebar-timestamp';
timestamp.textContent = formatCueTimestamp(cue.startTime);
const text = document.createElement('div');
text.className = 'subtitle-sidebar-text';
text.textContent = cue.text;
row.appendChild(timestamp);
row.appendChild(text);
ctx.dom.subtitleSidebarList.appendChild(row);
}
}
function syncActiveCueClasses(previousActiveCueIndex: number): void {
if (previousActiveCueIndex >= 0) {
const previous = ctx.dom.subtitleSidebarList.children[previousActiveCueIndex] as
| HTMLElement
| undefined;
previous?.classList.remove('active');
}
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
| HTMLElement
| undefined;
current?.classList.add('active');
}
}
function updateActiveCue(
current: { text: string; startTime?: number | null } | null,
currentTimeSec: number | null = null,
): void {
const previousActiveCueIndex = ctx.state.subtitleSidebarActiveCueIndex;
ctx.state.subtitleSidebarActiveCueIndex = findActiveSubtitleCueIndex(
ctx.state.subtitleSidebarCues,
current,
currentTimeSec,
previousActiveCueIndex,
);
if (ctx.state.subtitleSidebarModalOpen) {
syncActiveCueClasses(previousActiveCueIndex);
maybeAutoScrollActiveCue(previousActiveCueIndex);
}
}
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
applyConfig(snapshot);
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
if (cuesChanged) {
ctx.state.subtitleSidebarCues = snapshot.cues;
if (ctx.state.subtitleSidebarModalOpen) {
renderCueList();
}
}
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
syncEmbeddedSidebarLayout();
return snapshot;
}
function startSnapshotPolling(): void {
if (snapshotPollInterval) {
clearInterval(snapshotPollInterval);
}
snapshotPollInterval = setInterval(() => {
void refreshSnapshot();
}, SNAPSHOT_POLL_INTERVAL_MS);
}
function stopSnapshotPolling(): void {
if (!snapshotPollInterval) {
return;
}
clearInterval(snapshotPollInterval);
snapshotPollInterval = null;
}
async function openSubtitleSidebarModal(): Promise<void> {
const snapshot = await refreshSnapshot();
ctx.dom.subtitleSidebarList.innerHTML = '';
if (!snapshot.config.enabled) {
setStatus('Subtitle sidebar disabled in config.');
} else if (snapshot.cues.length === 0) {
setStatus('No parsed subtitle cues available.');
} else {
setStatus(`${snapshot.cues.length} parsed subtitle lines`);
}
ctx.state.subtitleSidebarModalOpen = true;
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
renderCueList();
syncActiveCueClasses(-1);
maybeAutoScrollActiveCue(-1, 'auto', true);
startSnapshotPolling();
syncEmbeddedSidebarLayout();
}
function closeSubtitleSidebarModal(): void {
if (!ctx.state.subtitleSidebarModalOpen) {
return;
}
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
stopSnapshotPolling();
syncEmbeddedSidebarLayout();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
async function toggleSubtitleSidebarModal(): Promise<void> {
if (ctx.state.subtitleSidebarModalOpen) {
closeSubtitleSidebarModal();
return;
}
await openSubtitleSidebarModal();
}
function handleSubtitleUpdated(data: SubtitleData): void {
if (ctx.state.subtitleSidebarModalOpen) {
return;
}
updateActiveCue(
{ text: data.text, startTime: data.startTime },
data.startTime ?? null,
);
}
function wireDomEvents(): void {
ctx.dom.subtitleSidebarClose.addEventListener('click', () => {
closeSubtitleSidebarModal();
});
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const row = target.closest<HTMLElement>('.subtitle-sidebar-item');
if (!row) {
return;
}
const index = Number.parseInt(row.dataset.index ?? '', 10);
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
return;
}
const cue = ctx.state.subtitleSidebarCues[index];
if (!cue) {
return;
}
seekToCue(cue);
});
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
return;
}
const paused = await window.electronAPI.getPlaybackPaused();
if (paused === false) {
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
ctx.state.subtitleSidebarPausedByHover = true;
}
});
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
if (!ctx.state.subtitleSidebarPausedByHover) {
return;
}
ctx.state.subtitleSidebarPausedByHover = false;
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
});
window.addEventListener('resize', () => {
if (!ctx.state.subtitleSidebarModalOpen) {
return;
}
syncEmbeddedSidebarLayout();
});
}
return {
openSubtitleSidebarModal,
closeSubtitleSidebarModal,
toggleSubtitleSidebarModal,
refreshSubtitleSidebarSnapshot: refreshSnapshot,
wireDomEvents,
handleSubtitleUpdated,
seekToCue,
};
}

View File

@@ -34,6 +34,7 @@ import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createPositioningController } from './positioning.js';
@@ -78,7 +79,8 @@ function isAnyModalOpen(): boolean {
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.sessionHelpModalOpen
ctx.state.sessionHelpModalOpen ||
ctx.state.subtitleSidebarModalOpen
);
}
@@ -114,6 +116,9 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
});
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -143,6 +148,9 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
},
toggleSubtitleSidebarModal: () => {
void subtitleSidebarModal.toggleSubtitleSidebarModal();
},
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
@@ -183,6 +191,7 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
function getActiveModal(): string | null {
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
@@ -198,6 +207,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.controllerDebugModalOpen) {
controllerDebugModal.closeControllerDebugModal();
}
if (ctx.state.subtitleSidebarModalOpen) {
subtitleSidebarModal.closeSubtitleSidebarModal();
}
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
@@ -468,6 +480,7 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
@@ -528,6 +541,7 @@ async function init(): Promise<void> {
controllerSelectModal.wireDomEvents();
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runGuarded('runtime-options:changed', () => {
@@ -539,6 +553,11 @@ async function init(): Promise<void> {
keyboardHandlers.updateKeybindings(payload.keybindings);
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
ctx.state.subtitleSidebarPauseVideoOnHover = payload.subtitleSidebar.pauseVideoOnHover;
ctx.state.subtitleSidebarAutoScroll = payload.subtitleSidebar.autoScroll;
void subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
measurementReporter.schedule();
});
});
@@ -555,6 +574,7 @@ async function init(): Promise<void> {
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),

View File

@@ -10,6 +10,8 @@ import type {
RuntimeOptionState,
RuntimeOptionValue,
SubtitlePosition,
SubtitleCue,
SubtitleSidebarConfig,
SubsyncSourceTrack,
} from '../types';
@@ -58,6 +60,7 @@ export type RendererState = {
controllerSelectModalOpen: boolean;
controllerDebugModalOpen: boolean;
subtitleSidebarModalOpen: boolean;
controllerDeviceSelectedIndex: number;
controllerConfig: ResolvedControllerConfig | null;
connectedGamepads: ControllerDeviceInfo[];
@@ -67,6 +70,14 @@ export type RendererState = {
sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number;
subtitleSidebarCues: SubtitleCue[];
subtitleSidebarActiveCueIndex: number;
subtitleSidebarToggleKey: string;
subtitleSidebarPauseVideoOnHover: boolean;
subtitleSidebarAutoScroll: boolean;
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
subtitleSidebarManualScrollUntilMs: number;
subtitleSidebarPausedByHover: boolean;
knownWordColor: string;
nPlusOneColor: string;
@@ -139,6 +150,7 @@ export function createRendererState(): RendererState {
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
subtitleSidebarModalOpen: false,
controllerDeviceSelectedIndex: 0,
controllerConfig: null,
connectedGamepads: [],
@@ -148,6 +160,14 @@ export function createRendererState(): RendererState {
sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0,
subtitleSidebarCues: [],
subtitleSidebarActiveCueIndex: -1,
subtitleSidebarToggleKey: 'Backslash',
subtitleSidebarPauseVideoOnHover: false,
subtitleSidebarAutoScroll: true,
subtitleSidebarConfig: null,
subtitleSidebarManualScrollUntilMs: 0,
subtitleSidebarPausedByHover: false,
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',

View File

@@ -40,6 +40,10 @@ body {
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
}
:root {
--subtitle-sidebar-reserved-width: 0px;
}
#overlay {
position: relative;
width: 100%;
@@ -294,13 +298,19 @@ body {
}
}
body.subtitle-sidebar-embedded-open #subtitleContainer {
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
}
#subtitleContainer {
max-width: 80%;
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
margin-bottom: 60px;
padding: 12px 20px;
background: rgb(30, 32, 48, 0.88);
border-radius: 8px;
pointer-events: auto;
transform: translateX(0);
}
#subtitleRoot {
@@ -705,6 +715,11 @@ body.platform-macos.layer-visible #subtitleRoot {
background: transparent;
}
body.subtitle-sidebar-embedded-open #secondarySubContainer {
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
transform: translateX(calc(-50% - (var(--subtitle-sidebar-reserved-width) * 0.5)));
}
#secondarySubContainer {
--secondary-sub-background-color: transparent;
--secondary-sub-backdrop-filter: none;
@@ -712,13 +727,14 @@ body.platform-macos.layer-visible #subtitleRoot {
top: 40px;
left: 50%;
transform: translateX(-50%);
max-width: 80%;
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
padding: 10px 18px;
background: var(--secondary-sub-background-color, transparent);
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
border-radius: 8px;
pointer-events: auto;
transform: translateX(-50%);
}
body.layer-modal #subtitleContainer,
@@ -763,6 +779,10 @@ body.settings-modal-open #secondarySubContainer {
display: none !important;
}
body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
}
#secondarySubContainer.secondary-sub-hover {
opacity: 0;
transition: opacity 0.2s ease;
@@ -1362,6 +1382,201 @@ iframe[id^='yomitan-popup'] {
white-space: pre-wrap;
}
.subtitle-sidebar-modal {
inset: 0;
justify-content: flex-end;
align-items: flex-start;
padding: 14px;
background: transparent;
pointer-events: none;
}
body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
padding: 0;
align-items: stretch;
}
.subtitle-sidebar-content {
width: min(var(--subtitle-sidebar-max-width, 420px), 92vw);
max-height: calc(100vh - 28px);
height: auto;
margin-left: auto;
font-family:
var(
--subtitle-sidebar-font-family,
'M PLUS 1',
'Noto Sans CJK JP',
'Hiragino Sans',
sans-serif
);
font-size: var(--subtitle-sidebar-font-size, 16px);
background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9));
color: var(--subtitle-sidebar-text-color, #cad3f5);
border: 1px solid rgba(110, 115, 141, 0.18);
border-radius: 10px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(183, 189, 248, 0.06);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
opacity: var(--subtitle-sidebar-opacity, 0.95);
pointer-events: auto;
}
.subtitle-sidebar-content .modal-header {
padding: 10px 14px 8px;
border-bottom: 1px solid rgba(110, 115, 141, 0.14);
gap: 8px;
}
.subtitle-sidebar-content .modal-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
color: #b8c0e0;
text-transform: uppercase;
}
.subtitle-sidebar-content .modal-close {
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
background: rgba(73, 77, 100, 0.5);
border: 1px solid rgba(110, 115, 141, 0.2);
color: #a5adcb;
transition: all 140ms ease;
}
.subtitle-sidebar-content .modal-close:hover {
background: rgba(91, 96, 120, 0.6);
color: #cad3f5;
border-color: rgba(110, 115, 141, 0.35);
}
body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
width: min(var(--subtitle-sidebar-max-width, 420px), 44vw);
max-height: 100vh;
height: 100vh;
border-radius: 0;
border-top: none;
border-right: none;
border-bottom: none;
box-shadow:
-12px 0 32px rgba(0, 0, 0, 0.3),
-1px 0 0 rgba(110, 115, 141, 0.12);
}
.subtitle-sidebar-body {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
.subtitle-sidebar-content .runtime-options-status {
font-size: 11px;
padding: 4px 14px;
color: #6e738d;
letter-spacing: 0.02em;
}
.subtitle-sidebar-list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
min-height: 0;
border-radius: 0;
background: transparent;
scroll-behavior: smooth;
}
.subtitle-sidebar-list::-webkit-scrollbar {
width: 6px;
}
.subtitle-sidebar-list::-webkit-scrollbar-track {
background: transparent;
}
.subtitle-sidebar-list::-webkit-scrollbar-thumb {
background: rgba(110, 115, 141, 0.25);
border-radius: 3px;
}
.subtitle-sidebar-list::-webkit-scrollbar-thumb:hover {
background: rgba(110, 115, 141, 0.4);
}
.subtitle-sidebar-item {
display: grid;
grid-template-columns: 52px 1fr;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease;
position: relative;
}
.subtitle-sidebar-item:last-child {
border-bottom: none;
}
.subtitle-sidebar-item:hover {
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
}
.subtitle-sidebar-item.active {
background: var(--subtitle-sidebar-active-background-color, rgba(138, 173, 244, 0.12));
}
.subtitle-sidebar-item.active::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--subtitle-sidebar-active-line-color, #f5bde6);
opacity: 0.85;
}
.subtitle-sidebar-timestamp {
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
color: var(--subtitle-sidebar-timestamp-color, #6e738d);
padding-top: 2px;
}
.subtitle-sidebar-item:hover .subtitle-sidebar-timestamp {
color: var(--subtitle-sidebar-timestamp-color, #a5adcb);
}
.subtitle-sidebar-item.active .subtitle-sidebar-timestamp {
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
opacity: 0.75;
}
.subtitle-sidebar-item.active .subtitle-sidebar-text {
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
}
.subtitle-sidebar-text {
white-space: pre-wrap;
line-height: 1.5;
font-size: 1em;
color: var(--subtitle-sidebar-text-color, #cad3f5);
}
.session-help-content {
width: min(760px, 92%);
max-height: 84%;

View File

@@ -74,6 +74,11 @@ export type RendererDom = {
controllerDebugAxes: HTMLPreElement;
controllerDebugButtons: HTMLPreElement;
controllerDebugButtonIndices: HTMLPreElement;
subtitleSidebarModal: HTMLDivElement;
subtitleSidebarContent: HTMLDivElement;
subtitleSidebarClose: HTMLButtonElement;
subtitleSidebarStatus: HTMLDivElement;
subtitleSidebarList: HTMLUListElement;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
@@ -171,6 +176,11 @@ export function resolveRendererDom(): RendererDom {
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
'controllerDebugButtonIndices',
),
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),

View File

@@ -7,6 +7,7 @@ export const OVERLAY_HOSTED_MODALS = [
'kiku',
'controller-select',
'controller-debug',
'subtitle-sidebar',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -38,6 +39,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitle: 'get-current-subtitle',
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style',

View File

@@ -364,6 +364,32 @@ export interface ResolvedTokenPos2ExclusionConfig {
export type FrequencyDictionaryMode = 'single' | 'banded';
export interface SubtitleCue {
startTime: number;
endTime: number;
text: string;
}
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
export interface SubtitleSidebarConfig {
enabled?: boolean;
layout?: SubtitleSidebarLayout;
toggleKey?: string;
pauseVideoOnHover?: boolean;
autoScroll?: boolean;
maxWidth?: number;
opacity?: number;
backgroundColor?: string;
textColor?: string;
fontFamily?: string;
fontSize?: number;
timestampColor?: string;
activeLineColor?: string;
activeLineBackgroundColor?: string;
hoverLineBackgroundColor?: string;
}
export interface ShortcutsConfig {
toggleVisibleOverlayGlobal?: string | null;
copySubtitle?: string | null;
@@ -675,6 +701,7 @@ export interface Config {
subsync?: SubsyncConfig;
startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig;
subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean;
jimaku?: JimakuConfig;
anilist?: AnilistConfig;
@@ -807,6 +834,7 @@ export interface ResolvedConfig {
bandedColors: [string, string, string, string, string];
};
};
subtitleSidebar: Required<SubtitleSidebarConfig>;
auto_start_overlay: boolean;
jimaku: JimakuConfig & {
apiBaseUrl: string;
@@ -939,6 +967,19 @@ export interface ClipboardAppendResult {
export interface SubtitleData {
text: string;
tokens: MergedToken[] | null;
startTime?: number | null;
endTime?: number | null;
}
export interface SubtitleSidebarSnapshot {
cues: SubtitleCue[];
currentTimeSec?: number | null;
currentSubtitle: {
text: string;
startTime: number | null;
endTime: number | null;
};
config: Required<SubtitleSidebarConfig>;
}
export interface MpvSubtitleRenderMetrics {
@@ -1057,6 +1098,7 @@ export type JimakuDownloadResult =
export interface ConfigHotReloadPayload {
keybindings: Keybinding[];
subtitleStyle: SubtitleStyleConfig | null;
subtitleSidebar: Required<SubtitleSidebarConfig>;
secondarySubMode: SecondarySubMode;
}
@@ -1075,6 +1117,7 @@ export interface ElectronAPI {
getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
@@ -1134,7 +1177,8 @@ export interface ElectronAPI {
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug',
| 'controller-debug'
| 'subtitle-sidebar',
) => void;
notifyOverlayModalOpened: (
modal:
@@ -1143,7 +1187,8 @@ export interface ElectronAPI {
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug',
| 'controller-debug'
| 'subtitle-sidebar',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;