feat(config): add configuration window (#70)

This commit is contained in:
2026-05-21 04:16:21 -07:00
committed by GitHub
parent a54f03f0cd
commit dc52bc2fba
287 changed files with 14507 additions and 8134 deletions
@@ -0,0 +1,81 @@
import type { SessionHelpSection } from './session-help-sections';
export type SessionHelpSubtitleStyle = {
knownWordColor?: unknown;
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
N1?: unknown;
N2?: unknown;
N3?: unknown;
N4?: unknown;
N5?: unknown;
};
};
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: 'Character names',
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
},
{
shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: 'JLPT N2',
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: 'JLPT N3',
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: 'JLPT N4',
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: 'JLPT N5',
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
@@ -0,0 +1,78 @@
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-item';
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement('div');
left.className = 'session-help-item-left';
const shortcut = document.createElement('span');
shortcut.className = 'session-help-key';
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement('div');
right.className = 'session-help-item-right';
const action = document.createElement('span');
action.className = 'session-help-action';
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement('span');
dot.className = 'session-help-color-dot';
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
button.appendChild(left);
button.appendChild(right);
return button;
}
const SECTION_ICON: Record<string, string> = {
'Playback and navigation': '▶',
'Visual feedback': '◉',
'Subtitle sync': '⟲',
'Mining and capture': '✦',
'Stats and progress': '◉',
'Overlay controls': '◈',
'Modals and tools': '▣',
'Runtime settings': '⚙',
'System actions': '◆',
'Other shortcuts': '…',
'Fixed overlay controls': '◇',
'Y chords': '⌘',
'Global shortcuts': '◆',
'Color legend': '◈',
};
export function createSessionHelpSectionNode(
section: SessionHelpSection,
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement('section');
sectionNode.className = 'session-help-section';
const title = document.createElement('h3');
title.className = 'session-help-section-title';
const icon = SECTION_ICON[section.title] ?? '•';
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement('div');
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
sectionNode.appendChild(list);
return sectionNode;
}
@@ -0,0 +1,472 @@
import type {
CompiledSessionBinding,
SessionActionId,
SessionKeyModifier,
SessionKeySpec,
} from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors';
export type SessionHelpItem = {
shortcut: string;
action: string;
color?: string;
};
export type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
export type SessionHelpTabId = 'essentials' | 'playback' | 'mining' | 'tools' | 'reference';
export type SessionHelpTab = {
id: SessionHelpTabId;
label: string;
};
export const SESSION_HELP_TABS: SessionHelpTab[] = [
{ id: 'essentials', label: 'Essentials' },
{ id: 'playback', label: 'Playback' },
{ id: 'mining', label: 'Mining' },
{ id: 'tools', label: 'Tools' },
{ id: 'reference', label: 'Reference' },
];
const KEY_NAME_MAP: Record<string, string> = {
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
Slash: '/',
Backslash: '\\',
Backquote: '`',
BracketLeft: '[',
BracketRight: ']',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Cmd: 'Cmd',
Shift: 'Shift',
Alt: 'Alt',
Super: 'Meta',
Meta: 'Meta',
Backspace: 'Backspace',
};
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith('Key')) return token.slice(3);
if (token.startsWith('Digit')) return token.slice(5);
if (token.startsWith('Numpad')) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts.map(normalizeKeyToken), normalizeKeyToken(key)];
return normalized.join(' + ');
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Unknown action';
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
if (first === 'seek' && typeof command[1] === 'number') {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
if (command[1] > 0) return 'Jump to next subtitle';
if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${
rawDirection === 'prev' ? 'previous' : 'next'
}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
export {
describeCommand as describeSessionHelpCommand,
formatKeybinding as formatSessionHelpKeybinding,
};
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Other shortcuts';
if (
first === 'cycle' ||
first === 'seek' ||
first === 'sub-seek' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return 'Playback and navigation';
}
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
return 'Visual feedback';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return 'Subtitle sync';
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';
}
if (first === 'quit') return 'System actions';
return 'Other shortcuts';
}
const MODIFIER_LABELS: Record<SessionKeyModifier, string> = {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
meta: 'Meta',
};
function formatSessionKeySpec(key: SessionKeySpec): string {
return [
...key.modifiers.map((modifier) => MODIFIER_LABELS[modifier]),
normalizeKeyToken(key.code),
]
.filter(Boolean)
.join(' + ');
}
function describeSessionAction(
actionId: SessionActionId,
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
): string {
switch (actionId) {
case 'toggleStatsOverlay':
return 'Toggle stats overlay';
case 'toggleVisibleOverlay':
return 'Show/hide visible overlay';
case 'copySubtitle':
return 'Copy subtitle';
case 'copySubtitleMultiple':
return 'Copy subtitle (multi)';
case 'updateLastCardFromClipboard':
return 'Update last card from clipboard';
case 'triggerFieldGrouping':
return 'Trigger field grouping';
case 'triggerSubsync':
return 'Open subtitle sync controls';
case 'mineSentence':
return 'Mine sentence';
case 'mineSentenceMultiple':
return 'Mine sentence (multi)';
case 'toggleSecondarySub':
return 'Toggle secondary subtitle mode';
case 'toggleSubtitleSidebar':
return 'Toggle subtitle sidebar';
case 'markAudioCard':
return 'Mark audio card';
case 'markWatched':
return 'Mark video watched';
case 'openRuntimeOptions':
return 'Open runtime options';
case 'openSessionHelp':
return 'Open session help';
case 'openCharacterDictionary':
return 'Open character dictionary anime selector';
case 'openControllerSelect':
return 'Open controller select';
case 'openControllerDebug':
return 'Open controller debug';
case 'openJimaku':
return 'Open jimaku';
case 'openYoutubePicker':
return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser':
return 'Open playlist browser';
case 'replayCurrentSubtitle':
return 'Replay current subtitle';
case 'playNextSubtitle':
return 'Play next subtitle';
case 'shiftSubDelayPrevLine':
return 'Shift subtitle delay to previous cue';
case 'shiftSubDelayNextLine':
return 'Shift subtitle delay to next cue';
case 'cycleRuntimeOption':
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next'
}`;
}
}
function sectionForSessionBinding(binding: CompiledSessionBinding): string {
if (binding.actionType === 'mpv-command') return sectionForCommand(binding.command);
switch (binding.actionId) {
case 'copySubtitle':
case 'copySubtitleMultiple':
case 'updateLastCardFromClipboard':
case 'triggerFieldGrouping':
case 'mineSentence':
case 'mineSentenceMultiple':
case 'markAudioCard':
return 'Mining and capture';
case 'toggleStatsOverlay':
case 'markWatched':
return 'Stats and progress';
case 'toggleVisibleOverlay':
case 'toggleSecondarySub':
case 'toggleSubtitleSidebar':
return 'Overlay controls';
case 'triggerSubsync':
return 'Subtitle sync';
case 'openRuntimeOptions':
case 'openJimaku':
case 'openCharacterDictionary':
case 'openControllerSelect':
case 'openControllerDebug':
case 'openYoutubePicker':
case 'openPlaylistBrowser':
case 'openSessionHelp':
return 'Modals and tools';
case 'replayCurrentSubtitle':
case 'playNextSubtitle':
case 'shiftSubDelayPrevLine':
case 'shiftSubDelayNextLine':
return 'Playback and navigation';
case 'cycleRuntimeOption':
return 'Runtime settings';
}
}
function buildSessionBindingSections(
sessionBindings: CompiledSessionBinding[],
): SessionHelpSection[] {
const grouped = new Map<string, SessionHelpItem[]>();
for (const binding of sessionBindings) {
const section = sectionForSessionBinding(binding);
const row: SessionHelpItem = {
shortcut: formatSessionKeySpec(binding.key),
action:
binding.actionType === 'mpv-command'
? describeCommand(binding.command)
: describeSessionAction(binding.actionId, binding.payload),
};
grouped.set(section, [...(grouped.get(section) ?? []), row]);
}
const sectionOrder = [
'Playback and navigation',
'Mining and capture',
'Stats and progress',
'Overlay controls',
'Subtitle sync',
'Runtime settings',
'Modals and tools',
'Visual feedback',
'System actions',
'Other shortcuts',
];
return Array.from(grouped.entries())
.sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
const bIdx = sectionOrder.indexOf(b[0]);
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
})
.map(([title, rows]) => ({ title, rows }));
}
function buildConfiguredOverlaySections(input: {
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
}): SessionHelpSection[] {
const statsRows: SessionHelpItem[] = [];
if (input.markWatchedKey) {
statsRows.push({
shortcut: formatKeybinding(input.markWatchedKey),
action: 'Mark video watched',
});
}
const overlayRows: SessionHelpItem[] = [];
if (input.subtitleSidebarToggleKey) {
overlayRows.push({
shortcut: formatKeybinding(input.subtitleSidebarToggleKey),
action: 'Toggle subtitle sidebar',
});
}
return [
...(statsRows.length > 0 ? [{ title: 'Stats and progress', rows: statsRows }] : []),
...(overlayRows.length > 0 ? [{ title: 'Overlay controls', rows: overlayRows }] : []),
];
}
function buildFixedOverlaySections(): SessionHelpSection[] {
return [
{
title: 'Fixed overlay controls',
rows: [
{ shortcut: 'V', action: 'Toggle primary subtitle bar visibility' },
{ shortcut: 'Ctrl/Cmd + A', action: 'Append clipboard video path to playlist' },
{ shortcut: 'Right-click', action: 'Toggle playback outside subtitle area' },
{ shortcut: 'Right-click + drag', action: 'Reposition subtitles on subtitle area' },
],
},
{
title: 'Y chords',
rows: [
{ shortcut: 'Y then Y', action: 'Open SubMiner menu' },
{ shortcut: 'Y then S', action: 'Start overlay' },
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
{ shortcut: 'Y then R', action: 'Restart overlay' },
{ shortcut: 'Y then C', action: 'Check overlay status' },
{ shortcut: 'Y then H/K', action: 'Open session help' },
{ shortcut: 'Y then D', action: 'Toggle DevTools' },
],
},
{
title: 'Global shortcuts',
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
},
];
}
function mergeSectionsByTitle(sections: SessionHelpSection[]): SessionHelpSection[] {
const merged: SessionHelpSection[] = [];
const byTitle = new Map<string, SessionHelpSection>();
for (const section of sections) {
const existing = byTitle.get(section.title);
if (existing) {
existing.rows.push(...section.rows);
continue;
}
const next = { title: section.title, rows: [...section.rows] };
byTitle.set(section.title, next);
merged.push(next);
}
return merged;
}
export function buildSessionHelpSections(input: {
sessionBindings: CompiledSessionBinding[];
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
}): SessionHelpSection[] {
const sessionBindings = input.sessionBindings.filter((binding) => {
if (binding.actionType !== 'session-action') return true;
if (input.markWatchedKey && binding.actionId === 'markWatched') return false;
if (input.subtitleSidebarToggleKey && binding.actionId === 'toggleSubtitleSidebar') {
return false;
}
return true;
});
return mergeSectionsByTitle([
...buildSessionBindingSections(sessionBindings),
...buildConfiguredOverlaySections({
markWatchedKey: input.markWatchedKey,
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
}),
...buildFixedOverlaySections(),
buildColorSection(input.subtitleStyle ?? {}),
]);
}
export function getSessionHelpSectionTabId(section: SessionHelpSection): SessionHelpTabId {
switch (section.title) {
case 'Stats and progress':
case 'Overlay controls':
case 'Fixed overlay controls':
case 'Global shortcuts':
return 'essentials';
case 'Playback and navigation':
case 'Subtitle sync':
case 'Visual feedback':
case 'System actions':
return 'playback';
case 'Mining and capture':
return 'mining';
case 'Modals and tools':
case 'Runtime settings':
return 'tools';
case 'Y chords':
case 'Color legend':
case 'Other shortcuts':
default:
return 'reference';
}
}
export function filterSessionHelpSections(
sections: SessionHelpSection[],
query: string,
): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, 'ctrl')
.replace(/cmd\/ctrl/gu, 'ctrl')
.replace(/[\s+\-_/]/gu, '');
const normalized = normalize(query);
if (!normalized) return sections;
return sections
.map((section) => {
if (normalize(section.title).includes(normalized)) {
return section;
}
const rows = section.rows.filter(
(row) =>
normalize(row.shortcut).includes(normalized) ||
normalize(row.action).includes(normalized),
);
if (rows.length === 0) return null;
return { ...section, rows };
})
.filter((section): section is SessionHelpSection => section !== null)
.filter((section) => section.rows.length > 0);
}
+50
View File
@@ -0,0 +1,50 @@
import {
filterSessionHelpSections,
getSessionHelpSectionTabId,
SESSION_HELP_TABS,
type SessionHelpSection,
type SessionHelpTabId,
} from './session-help-sections';
function countRows(sections: SessionHelpSection[]): number {
return sections.reduce((count, section) => count + section.rows.length, 0);
}
function sectionMatchesTab(section: SessionHelpSection, tabId: SessionHelpTabId): boolean {
return getSessionHelpSectionTabId(section) === tabId;
}
export function buildVisibleSessionHelpSections(
sections: SessionHelpSection[],
tabId: SessionHelpTabId,
query: string,
): SessionHelpSection[] {
if (query.trim()) return filterSessionHelpSections(sections, query);
return sections.filter((section) => sectionMatchesTab(section, tabId));
}
export function createSessionHelpTabBar(
sections: SessionHelpSection[],
activeTabId: SessionHelpTabId,
onSelect: (tabId: SessionHelpTabId) => void,
): HTMLElement {
const tabBar = document.createElement('div');
tabBar.className = 'session-help-tabs';
for (const tab of SESSION_HELP_TABS) {
const tabSections = sections.filter((section) => sectionMatchesTab(section, tab.id));
if (tabSections.length === 0) continue;
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-tab';
button.dataset.sessionHelpTab = tab.id;
button.setAttribute('aria-pressed', String(tab.id === activeTabId));
if (tab.id === activeTabId) button.classList.add('active');
button.textContent = `${tab.label} ${countRows(tabSections)}`;
button.addEventListener('click', () => onSelect(tab.id));
tabBar.appendChild(button);
}
return tabBar;
}
+84 -5
View File
@@ -1,9 +1,12 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { createRendererState } from '../state.js';
import {
buildSessionHelpSections,
createSessionHelpModal,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
@@ -30,6 +33,81 @@ test('session help formats bracket keybindings as physical keys', () => {
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
});
test('session help normalizes configured modifier aliases', () => {
assert.equal(formatSessionHelpKeybinding('CommandOrControl+KeyS'), 'Cmd/Ctrl + S');
});
test('session help imports browser-safe special command constants', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help-sections.ts'),
'utf8',
);
assert.match(source, /from ['"]\.\.\/\.\.\/config\/definitions\/shared['"]/);
assert.doesNotMatch(source, /from ['"]\.\.\/\.\.\/config\/definitions['"]/);
});
test('session help builds rows from canonical session bindings and fixed overlay affordances', () => {
const sections = buildSessionHelpSections({
sessionBindings: [
{
sourcePath: 'stats.toggleKey',
originalKey: 'Backquote',
key: { code: 'Backquote', modifiers: [] },
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
},
{
sourcePath: 'shortcuts.openSessionHelp',
originalKey: 'CommandOrControl+Slash',
key: { code: 'Slash', modifiers: ['ctrl'] },
actionType: 'session-action',
actionId: 'openSessionHelp',
},
{
sourcePath: 'shortcuts.toggleSubtitleSidebar',
originalKey: 'Backslash',
key: { code: 'Backslash', modifiers: [] },
actionType: 'session-action',
actionId: 'toggleSubtitleSidebar',
},
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'KeyW',
key: { code: 'KeyW', modifiers: [] },
actionType: 'session-action',
actionId: 'markWatched',
},
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
],
markWatchedKey: 'KeyW',
subtitleSidebarToggleKey: 'KeyB',
subtitleStyle: {},
});
const rows = sections.flatMap((section) => section.rows);
assert.ok(rows.some((row) => row.shortcut === '`' && row.action === 'Toggle stats overlay'));
assert.ok(rows.some((row) => row.shortcut === 'W' && row.action === 'Mark video watched'));
assert.equal(rows.filter((row) => row.action === 'Mark video watched').length, 1);
assert.equal(sections.filter((section) => section.title === 'Stats and progress').length, 1);
assert.ok(rows.some((row) => row.shortcut === 'B' && row.action === 'Toggle subtitle sidebar'));
assert.equal(rows.filter((row) => row.action === 'Toggle subtitle sidebar').length, 1);
assert.ok(rows.some((row) => row.shortcut === 'Ctrl + /' && row.action === 'Open session help'));
assert.ok(rows.some((row) => row.shortcut === 'Space' && row.action === 'Toggle playback'));
assert.ok(
rows.some(
(row) => row.shortcut === 'V' && row.action === 'Toggle primary subtitle bar visibility',
),
);
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
@@ -96,11 +174,12 @@ test('modal-layer session help does not focus hidden main overlay and still clos
notifyOverlayModalClosed: (modal: string) => {
notifications.push(modal);
},
getKeybindings: async () => {
throw new Error('mpv unavailable');
},
getSessionBindings: async () => [],
getSubtitleStyle: async () => ({}),
getConfiguredShortcuts: async () => ({}),
getMarkWatchedKey: async () => 'KeyW',
getSubtitleSidebarSnapshot: async () => ({
config: { toggleKey: 'Backslash' },
}),
},
focus: () => {},
addEventListener: () => {},
+42 -408
View File
@@ -1,7 +1,17 @@
import type { Keybinding } from '../../types';
import type { ShortcutsConfig } from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import type { ModalStateReader, RendererContext } from '../context';
import {
buildSessionHelpSections,
type SessionHelpSection,
type SessionHelpTabId,
} from './session-help-sections';
import { createSessionHelpSectionNode } from './session-help-render';
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
export {
buildSessionHelpSections,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
} from './session-help-sections';
type SessionHelpBindingInfo = {
bindingKey: 'KeyH' | 'KeyK';
@@ -9,314 +19,6 @@ type SessionHelpBindingInfo = {
fallbackUnavailable: boolean;
};
type SessionHelpItem = {
shortcut: string;
action: string;
color?: string;
};
type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, 'multiCopyTimeoutMs'>;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
};
const KEY_NAME_MAP: Record<string, string> = {
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
BracketLeft: '[',
BracketRight: ']',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Cmd: 'Cmd',
Shift: 'Shift',
Alt: 'Alt',
Super: 'Meta',
Meta: 'Meta',
Backspace: 'Backspace',
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith('Key')) return token.slice(3);
if (token.startsWith('Digit')) return token.slice(5);
if (token.startsWith('Numpad')) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split('+');
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
return normalized.join(' + ');
}
const OVERLAY_SHORTCUTS: Array<{
key: keyof RuntimeShortcutConfig;
label: string;
}> = [
{ key: 'copySubtitle', label: 'Copy subtitle' },
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
{
key: 'updateLastCardFromClipboard',
label: 'Update last card from clipboard',
},
{ key: 'triggerFieldGrouping', label: 'Trigger field grouping' },
{ key: 'triggerSubsync', label: 'Open subtitle sync controls' },
{ key: 'mineSentence', label: 'Mine sentence' },
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
{ key: 'markAudioCard', label: 'Mark audio card' },
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'openSessionHelp', label: 'Open session help' },
{ key: 'openControllerSelect', label: 'Open controller select' },
{ key: 'openControllerDebug', label: 'Open controller debug' },
{ key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' },
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
];
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {
const rows: SessionHelpItem[] = [];
for (const shortcut of OVERLAY_SHORTCUTS) {
const keybind = shortcuts[shortcut.key];
rows.push({
shortcut:
typeof keybind === 'string' && keybind.trim().length > 0
? formatKeybinding(keybind)
: 'Unbound',
action: shortcut.label,
});
}
if (rows.length === 0) return [];
return [{ title: 'Overlay shortcuts', rows }];
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Unknown action';
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
if (first === 'seek' && typeof command[1] === 'number') {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
if (command[1] > 0) return 'Jump to next subtitle';
if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
export {
describeCommand as describeSessionHelpCommand,
formatKeybinding as formatSessionHelpKeybinding,
};
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Other shortcuts';
if (
first === 'cycle' ||
first === 'seek' ||
first === 'sub-seek' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return 'Playback and navigation';
}
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
return 'Visual feedback';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return 'Subtitle sync';
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';
}
if (first === 'quit') return 'System actions';
return 'Other shortcuts';
}
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
const grouped = new Map<string, SessionHelpItem[]>();
for (const binding of keybindings) {
const section = sectionForCommand(binding.command ?? []);
const row: SessionHelpItem = {
shortcut: formatKeybinding(binding.key),
action: describeCommand(binding.command ?? []),
};
grouped.set(section, [...(grouped.get(section) ?? []), row]);
}
const sectionOrder = [
'Playback and navigation',
'Visual feedback',
'Subtitle sync',
'Runtime settings',
'System actions',
'Other shortcuts',
];
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
const bIdx = sectionOrder.indexOf(b[0]);
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
return sectionEntries.map(([title, rows]) => ({ title, rows }));
}
function buildColorSection(style: {
knownWordColor?: unknown;
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
N1?: unknown;
N2?: unknown;
N3?: unknown;
N4?: unknown;
N5?: unknown;
};
}): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: 'Character names',
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
},
{
shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: 'JLPT N2',
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: 'JLPT N3',
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: 'JLPT N4',
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: 'JLPT N5',
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, 'ctrl')
.replace(/cmd\/ctrl/gu, 'ctrl')
.replace(/[\s+\-_/]/gu, '');
const normalized = normalize(query);
if (!normalized) return sections;
return sections
.map((section) => {
if (normalize(section.title).includes(normalized)) {
return section;
}
const rows = section.rows.filter(
(row) =>
normalize(row.shortcut).includes(normalized) ||
normalize(row.action).includes(normalized),
);
if (rows.length === 0) return null;
return { ...section, rows };
})
.filter((section): section is SessionHelpSection => section !== null)
.filter((section) => section.rows.length > 0);
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
@@ -324,79 +26,6 @@ function formatBindingHint(info: SessionHelpBindingInfo): string {
return 'Y-H';
}
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-item';
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement('div');
left.className = 'session-help-item-left';
const shortcut = document.createElement('span');
shortcut.className = 'session-help-key';
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement('div');
right.className = 'session-help-item-right';
const action = document.createElement('span');
action.className = 'session-help-action';
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement('span');
dot.className = 'session-help-color-dot';
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
button.appendChild(left);
button.appendChild(right);
return button;
}
const SECTION_ICON: Record<string, string> = {
'MPV shortcuts': '⚙',
'Playback and navigation': '▶',
'Visual feedback': '◉',
'Subtitle sync': '⟲',
'Runtime settings': '⚙',
'System actions': '◆',
'Other shortcuts': '…',
'Overlay shortcuts (configurable)': '✦',
'Overlay shortcuts': '✦',
'Color legend': '◈',
};
function createSectionNode(
section: SessionHelpSection,
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement('section');
sectionNode.className = 'session-help-section';
const title = document.createElement('h3');
title.className = 'session-help-section-title';
const icon = SECTION_ICON[section.title] ?? '•';
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement('div');
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
sectionNode.appendChild(list);
return sectionNode;
}
export function createSessionHelpModal(
ctx: RendererContext,
options: {
@@ -412,6 +41,7 @@ export function createSessionHelpModal(
};
let helpFilterValue = '';
let helpSections: SessionHelpSection[] = [];
let activeTabId: SessionHelpTabId = 'essentials';
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
@@ -497,7 +127,7 @@ export function createSessionHelpModal(
}
function applyFilterAndRender(): void {
const sections = filterSections(helpSections, helpFilterValue);
const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue);
const indexOffsets: number[] = [];
let running = 0;
for (const section of sections) {
@@ -506,8 +136,16 @@ export function createSessionHelpModal(
}
ctx.dom.sessionHelpContent.innerHTML = '';
if (!helpFilterValue.trim()) {
ctx.dom.sessionHelpContent.appendChild(
createSessionHelpTabBar(helpSections, activeTabId, (tabId) => {
activeTabId = tabId;
applyFilterAndRender();
}),
);
}
sections.forEach((section, sectionIndex) => {
const sectionNode = createSectionNode(section, sectionIndex, indexOffsets);
const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets);
ctx.dom.sessionHelpContent.appendChild(sectionNode);
});
@@ -515,7 +153,7 @@ export function createSessionHelpModal(
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = helpFilterValue
? 'No matching shortcuts found.'
: 'No active session shortcuts found.';
: 'No active shortcuts in this tab.';
ctx.state.sessionHelpSelectedIndex = 0;
return;
}
@@ -572,6 +210,7 @@ export function createSessionHelpModal(
function showRenderError(message: string): void {
helpSections = [];
helpFilterValue = '';
activeTabId = 'essentials';
ctx.dom.sessionHelpFilter.value = '';
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = message;
@@ -580,28 +219,23 @@ export function createSessionHelpModal(
async function render(): Promise<boolean> {
try {
const [keybindings, styleConfig, shortcuts] = await Promise.all([
window.electronAPI.getKeybindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getConfiguredShortcuts(),
]);
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
]);
const bindingSections = buildBindingSections(keybindings);
if (bindingSections.length > 0) {
const playback = bindingSections.find(
(section) => section.title === 'Playback and navigation',
);
if (playback) {
playback.title = 'MPV shortcuts';
}
}
const shortcutSections = buildOverlayShortcutSections(shortcuts);
if (shortcutSections.length > 0) {
shortcutSections[0]!.title = 'Overlay shortcuts (configurable)';
}
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];
helpSections = buildSessionHelpSections({
sessionBindings,
markWatchedKey,
subtitleSidebarToggleKey,
subtitleStyle: styleConfig ?? {},
});
applyFilterAndRender();
return true;
} catch (error) {
+56 -1
View File
@@ -3,7 +3,11 @@ import test from 'node:test';
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import { createRendererState } from '../state.js';
import { createSubtitleSidebarModal, findActiveSubtitleCueIndex } from './subtitle-sidebar.js';
import {
applySidebarCssDeclarations,
createSubtitleSidebarModal,
findActiveSubtitleCueIndex,
} from './subtitle-sidebar.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
@@ -108,6 +112,42 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
});
test('applySidebarCssDeclarations clears declarations removed by config reload', () => {
const removed: string[] = [];
const style = {
color: '',
backgroundColor: '',
setProperty(property: string, value: string) {
(this as unknown as Record<string, string>)[property] = value;
},
removeProperty(property: string) {
removed.push(property);
delete (this as unknown as Record<string, string>)[property];
},
};
const target = { style } as unknown as HTMLElement;
applySidebarCssDeclarations(target, {
color: '#cad3f5',
'background-color': '#181926',
});
applySidebarCssDeclarations(target, {
color: '#ffffff',
});
assert.equal(style.color, '#ffffff');
assert.equal(style.backgroundColor, '');
assert.deepEqual(removed, ['background-color']);
applySidebarCssDeclarations(target, {
color: '',
'background-color': '',
});
assert.equal(style.color, '');
assert.deepEqual(removed, ['background-color', 'background-color']);
});
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;
@@ -141,6 +181,11 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
css: {
'font-size': '22px',
color: '#ffffff',
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
},
},
};
@@ -175,6 +220,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const cueList = createListStub();
const contentStyleValues = new Map<string, string>();
const contentStyle = {
setProperty: (name: string, value: string) => {
contentStyleValues.set(name, value);
},
} as CSSStyleDeclaration & { color?: string };
const ctx = {
dom: {
overlay: { classList: overlayClassList },
@@ -187,6 +238,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
style: contentStyle,
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -207,6 +259,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
assert.equal(cueList.children.length, 2);
assert.equal(cueList.scrollTop, 0);
assert.deepEqual(cueList.scrollToCalls, []);
assert.equal(contentStyleValues.get('font-size'), '22px');
assert.equal(contentStyle.color, '#ffffff');
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
+42
View File
@@ -8,6 +8,7 @@ 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;
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
function nowForUiTiming(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
@@ -55,6 +56,46 @@ function formatCueTimestamp(seconds: number): string {
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
export function applySidebarCssDeclarations(
target: HTMLElement,
declarations: Record<string, string>,
): void {
const targetStyle = (target as HTMLElement & { style?: CSSStyleDeclaration }).style;
if (!targetStyle) return;
const styleTarget = targetStyle as unknown as Record<string, string>;
const previousKeys = appliedSidebarCssKeys.get(target) ?? new Set<string>();
const nextKeys = new Set<string>();
for (const property of previousKeys) {
if (Object.prototype.hasOwnProperty.call(declarations, property)) continue;
if (property.includes('-')) {
targetStyle.removeProperty(property);
} else {
styleTarget[property] = '';
}
}
for (const [property, rawValue] of Object.entries(declarations)) {
const value = rawValue.trim();
if (value.length === 0) {
if (property.includes('-')) {
targetStyle.removeProperty(property);
} else {
styleTarget[property] = '';
}
continue;
}
if (property.includes('-')) {
targetStyle.setProperty(property, value);
} else {
styleTarget[property] = value;
}
nextKeys.add(property);
}
appliedSidebarCssKeys.set(target, nextKeys);
}
export function findActiveSubtitleCueIndex(
cues: SubtitleCue[],
current: { text: string; startTime?: number | null } | null,
@@ -266,6 +307,7 @@ export function createSubtitleSidebarModal(
'--subtitle-sidebar-hover-background-color',
snapshot.config.hoverLineBackgroundColor,
);
applySidebarCssDeclarations(ctx.dom.subtitleSidebarContent, snapshot.config.css ?? {});
}
function seekToCue(cue: SubtitleCue): void {