mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
feat(overlay): add optional subtitle selection modal (#265)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleSelection',
|
||||
'openSubtitleGeneration',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}"`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user