feat(overlay): add optional subtitle selection modal (#265)

This commit is contained in:
2026-09-22 18:30:26 -07:00
committed by GitHub
parent a7a302bdd0
commit b0fb39a8f5
54 changed files with 1397 additions and 52 deletions
+5
View File
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
export const OVERLAY_HOSTED_MODALS = [
'runtime-options',
'subsync',
'subtitle-selection',
'subtitle-generation',
'jimaku',
'tsukihime',
@@ -51,6 +52,8 @@ export const IPC_CHANNELS = {
dispatchSessionAction: 'session-action:dispatch',
},
request: {
getSubtitleSelection: 'subtitle-selection:get',
applySubtitleSelection: 'subtitle-selection:apply',
requestSubtitleGenerationOpen: 'subtitle-generation:open',
getSubtitleGenerationStatus: 'subtitle-generation:status',
startSubtitleGeneration: 'subtitle-generation:start',
@@ -143,6 +146,7 @@ export const IPC_CHANNELS = {
mediaTimingReviewResolve: 'media-timing-review:resolve',
},
event: {
subtitleSelectionOpen: 'subtitle-selection:opened',
subtitleGenerationOpen: 'subtitle-generation:opened',
subtitleGenerationProgress: 'subtitle-generation:progress',
subtitleSet: 'subtitle:set',
@@ -174,6 +178,7 @@ export const IPC_CHANNELS = {
controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
sessionBindingsChanged: 'session-bindings:changed',
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle',
+1
View File
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerDebug',
'openJimaku',
'openTsukihime',
'openSubtitleSelection',
'openSubtitleGeneration',
'openYoutubePicker',
'openPlaylistBrowser',
+10
View File
@@ -103,3 +103,13 @@ test('SubMiner ownership recognizes leading mpv prefixes without matching comman
['d', 'e', 'f', 'g'],
);
});
test('sequence conflict discovery allows winning ignore bindings used by mpv sequence prefixes', () => {
const bindings = [
{ key: 'g', cmd: 'show-text old', priority: 0 },
{ key: 'g', cmd: 'no-osd ignore', priority: 1 },
{ key: 'h', cmd: 'show-text action', priority: 0 },
];
assert.deepEqual(parseMpvInputBindingKeys(bindings), ['g', 'h']);
assert.deepEqual(parseMpvInputBindingKeys(bindings, { includeIgnored: false }), ['h']);
});
+13 -4
View File
@@ -59,9 +59,12 @@ export function keyboardEventToMpvKey(
return normalizeMpvInputKey([...modifiers, key].join('+'));
}
export function parseMpvInputBindingKeys(value: unknown): string[] {
export function parseMpvInputBindingKeys(
value: unknown,
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
): string[] {
if (!Array.isArray(value)) return [];
const bindings = new Map<string, { priority: number; owned: boolean }>();
const bindings = new Map<string, { priority: number; owned: boolean; ignored: boolean }>();
for (const candidate of value) {
const entry: unknown = candidate;
if (
@@ -94,8 +97,14 @@ export function parseMpvInputBindingKeys(value: unknown): string[] {
entry.priority > previous.priority ||
(entry.priority === previous.priority && owned)
) {
bindings.set(key, { priority: entry.priority, owned });
bindings.set(key, {
priority: entry.priority,
owned,
ignored: entry.cmd.trim().replace(MPV_COMMAND_PREFIXES, '') === 'ignore',
});
}
}
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key);
return [...bindings]
.filter(([, binding]) => !binding.owned && (includeIgnored || !binding.ignored))
.map(([key]) => key);
}
+71
View File
@@ -0,0 +1,71 @@
import type {
CompiledSessionBinding,
SessionBindingWarning,
SessionKeySpec,
} from '../types/session-bindings';
export interface SessionKeyReservation {
key: SessionKeySpec;
path: string;
}
export function getSessionSequencePrefix(key: SessionKeySpec): SessionKeySpec | null {
const match = /^(Key[A-Z])-Key[A-Z]$/.exec(key.code);
return match?.[1] ? { code: match[1], modifiers: key.modifiers } : null;
}
function signature(key: SessionKeySpec): string {
return [...key.modifiers, key.code].join('+');
}
export function resolveSessionSequenceConflicts(
bindings: CompiledSessionBinding[],
reservations: SessionKeyReservation[] = [],
): { bindings: CompiledSessionBinding[]; warnings: SessionBindingWarning[] } {
const singles = new Map<string, string[]>();
for (const { key, path } of [
...bindings.map((binding) => ({ key: binding.key, path: binding.sourcePath })),
...reservations,
]) {
if (getSessionSequencePrefix(key)) continue;
const id = signature(key);
singles.set(id, [...(singles.get(id) ?? []), path]);
}
const warnings: SessionBindingWarning[] = [];
const effective = bindings.filter((binding) => {
const prefix = getSessionSequencePrefix(binding.key);
if (!prefix) return true;
const conflicts = singles.get(signature(prefix));
if (!conflicts?.length) return true;
const paths = [...new Set(conflicts)];
warnings.push({
kind: 'conflict',
path: binding.sourcePath,
value: binding.originalKey,
conflictingPaths: paths,
message: `Disabled sequence "${binding.originalKey}" (${binding.sourcePath}): its first key is reserved by ${paths.join(', ')}. Single-key bindings take priority; remap the sequence or its conflicting binding.`,
});
return false;
});
return { bindings: effective, warnings };
}
// Imported mpv keys preserve case: g and G are different strokes.
export function reserveMpvSequencePrefixes(keys: string[]): SessionKeyReservation[] {
return keys.flatMap((value) => {
const parts = value.split('+');
const letter = parts.pop();
if (!letter || !/^[a-z]$/i.test(letter)) return [];
const modifiers: SessionKeySpec['modifiers'] = [];
if (parts.includes('ctrl')) modifiers.push('ctrl');
if (parts.includes('alt')) modifiers.push('alt');
if (parts.includes('shift') || /^[A-Z]$/.test(letter)) modifiers.push('shift');
if (parts.includes('meta')) modifiers.push('meta');
return [
{
key: { code: `Key${letter.toUpperCase()}`, modifiers },
path: `mpv input binding "${value}"`,
},
];
});
}
+33
View File
@@ -0,0 +1,33 @@
export interface SubtitleSelectionState {
mediaPath: string;
tracks: { id: number; label: string }[];
primary: number | null;
secondary: number | null;
}
export type SubtitleSelectionRequest = Pick<
SubtitleSelectionState,
'mediaPath' | 'primary' | 'secondary'
>;
export function parseSubtitleSelectionRequest(value: unknown): SubtitleSelectionRequest {
if (
typeof value !== 'object' ||
value === null ||
!('mediaPath' in value) ||
typeof value.mediaPath !== 'string' ||
!value.mediaPath ||
!('primary' in value) ||
!isTrackSelection(value.primary) ||
!('secondary' in value) ||
!isTrackSelection(value.secondary)
)
throw new Error('Invalid subtitle selection.');
if (value.primary !== null && value.primary === value.secondary)
throw new Error('Choose different primary and secondary tracks.');
return { mediaPath: value.mediaPath, primary: value.primary, secondary: value.secondary };
}
function isTrackSelection(value: unknown): value is number | null {
return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
}