Files
SubMiner/src/renderer/modals/session-help-sections.ts
T
sudacode f4845513f3 feat: add mark-watched action, background app reuse, and N+1 compat
- Add `--mark-watched` CLI flag + mpv session binding; marks video watched, shows OSD, advances playlist
- Launcher detects running background app via `--app-ping` and borrows it instead of owning its lifecycle
- Preserve N+1 highlighting for existing configs with `knownWords.highlightEnabled` set
- Fix `resolveConfiguredShortcuts` to respect explicit `null` overrides (disabling defaults)
- Split session-help modal into focused modules (colors, render, sections, tabs)
2026-05-19 01:30:49 -07:00

470 lines
14 KiB
TypeScript

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('+');
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, 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);
}