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:
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
if (
|
||||
diff.hotReloadFields.includes('shortcuts') ||
|
||||
diff.hotReloadFields.includes('subtitleSelection')
|
||||
) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
|
||||
const sequence: CompiledSessionBinding = {
|
||||
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
};
|
||||
let nativeKeys: unknown = [];
|
||||
let failDiscovery = false;
|
||||
let published: CompiledSessionBinding[] = [];
|
||||
const events: CompiledSessionBinding[][] = [];
|
||||
const warnings: string[] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
send: () => {},
|
||||
requestProperty: async () => {
|
||||
if (failDiscovery) throw new Error('temporarily unavailable');
|
||||
return nativeKeys;
|
||||
},
|
||||
};
|
||||
const runtime = createSessionBindingsRuntime({
|
||||
configDir: root,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
|
||||
getMpvClient: () => client,
|
||||
setSessionBindings: (bindings) => {
|
||||
published = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: () => {},
|
||||
logWarn: () => {},
|
||||
onBindingsChanged: (bindings) => events.push(bindings),
|
||||
onWarning: (warning) => warnings.push(warning.message),
|
||||
});
|
||||
const readArtifact = () =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
|
||||
try {
|
||||
runtime.persistSessionBindings([sequence]);
|
||||
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, []);
|
||||
assert.deepEqual(events.at(-1), readArtifact().bindings);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /mpv input binding "g"/);
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
|
||||
assert.equal(warnings.length, 1);
|
||||
failDiscovery = true;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
|
||||
failDiscovery = false;
|
||||
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
|
||||
assert.deepEqual(readArtifact().warnings, []);
|
||||
client.connected = false;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,16 +6,26 @@ import {
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
|
||||
import {
|
||||
reserveMpvSequencePrefixes,
|
||||
resolveSessionSequenceConflicts,
|
||||
} from '../../shared/session-key-sequences';
|
||||
import type { SessionBindingWarning } from '../../types/session-bindings';
|
||||
|
||||
export interface SessionBindingsRuntimeDeps {
|
||||
configDir: string;
|
||||
getKeybindings: () => Keybinding[];
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
getMpvClient: () =>
|
||||
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
|
||||
| null;
|
||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
|
||||
onWarning?: (warning: SessionBindingWarning) => void;
|
||||
}
|
||||
|
||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||
) => void;
|
||||
refreshCurrentSessionBindings: () => void;
|
||||
refreshMpvSessionBindings: () => Promise<void>;
|
||||
} {
|
||||
let sourceBindings: CompiledSessionBinding[] = [];
|
||||
let sourceWarnings: SessionBindingWarning[] = [];
|
||||
let nativeSnapshot: {
|
||||
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||
keys: string[];
|
||||
} | null = null;
|
||||
let pending: {
|
||||
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||
promise: Promise<void>;
|
||||
} | null = null;
|
||||
let publishedSignature: string | null = null;
|
||||
let reportedWarnings = new Set<string>();
|
||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
sourceBindings = bindings;
|
||||
sourceWarnings = warnings;
|
||||
publishBindings();
|
||||
}
|
||||
|
||||
function publishBindings(): void {
|
||||
const client = deps.getMpvClient();
|
||||
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
|
||||
const result = resolveSessionSequenceConflicts(
|
||||
sourceBindings,
|
||||
reserveMpvSequencePrefixes(keys),
|
||||
);
|
||||
const warnings = [...sourceWarnings, ...result.warnings];
|
||||
const signature = JSON.stringify([
|
||||
result.bindings,
|
||||
warnings,
|
||||
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
]);
|
||||
if (signature === publishedSignature) return;
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
bindings: result.bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||
throw error;
|
||||
}
|
||||
deps.setSessionBindings(bindings);
|
||||
publishedSignature = signature;
|
||||
deps.setSessionBindings(result.bindings);
|
||||
deps.setSessionBindingsInitialized(true);
|
||||
const nextWarnings = new Set(warnings.map((warning) => warning.message));
|
||||
for (const warning of warnings) {
|
||||
if (reportedWarnings.has(warning.message)) continue;
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
deps.onWarning?.(warning);
|
||||
}
|
||||
reportedWarnings = nextWarnings;
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
try {
|
||||
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||
}
|
||||
}
|
||||
deps.onBindingsChanged?.(result.bindings);
|
||||
}
|
||||
|
||||
async function refreshMpvSessionBindings(): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
nativeSnapshot = null;
|
||||
publishBindings();
|
||||
return;
|
||||
}
|
||||
if (pending?.client === client) return pending.promise;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const raw = await client.requestProperty('input-bindings');
|
||||
if (client !== deps.getMpvClient() || !client.connected) return;
|
||||
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
|
||||
publishBindings();
|
||||
} catch {
|
||||
// Keep the last successful snapshot if discovery is temporarily unavailable.
|
||||
}
|
||||
})();
|
||||
const request = { client, promise };
|
||||
pending = request;
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
if (pending === request) pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentSessionBindings(): void {
|
||||
const compiled = compileCurrentSessionBindings();
|
||||
for (const warning of compiled.warnings) {
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
}
|
||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||
void refreshMpvSessionBindings();
|
||||
}
|
||||
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSubtitleSelectionRuntime } from './subtitle-selection';
|
||||
|
||||
function setup() {
|
||||
let enabled = true;
|
||||
const properties = new Map<string, unknown>([
|
||||
['path', '/video.mkv'],
|
||||
[
|
||||
'track-list',
|
||||
[
|
||||
{ id: 1, type: 'audio' },
|
||||
{ id: 2, type: 'sub', title: 'Japanese', lang: 'ja', codec: 'ass' },
|
||||
{ id: 3, type: 'sub', title: 'English', lang: 'en', external: true },
|
||||
{ id: '4', type: 'sub' },
|
||||
],
|
||||
],
|
||||
['sid', 2],
|
||||
['secondary-sid', 3],
|
||||
]);
|
||||
const commands: unknown[][] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => properties.get(name),
|
||||
request: async (command: unknown[]) => {
|
||||
commands.push(command);
|
||||
return { error: 'success' };
|
||||
},
|
||||
};
|
||||
const runtime = createSubtitleSelectionRuntime({
|
||||
isEnabled: () => enabled,
|
||||
getMpvClient: () => client,
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
properties,
|
||||
commands,
|
||||
client,
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('subtitle selector lists only valid subtitle tracks and current selections', async () => {
|
||||
const { runtime, properties } = setup();
|
||||
assert.deepEqual(await runtime.getState(), {
|
||||
mediaPath: '/video.mkv',
|
||||
primary: 2,
|
||||
secondary: 3,
|
||||
tracks: [
|
||||
{ id: 2, label: '#2 · Japanese · ja · ass' },
|
||||
{ id: 3, label: '#3 · English · en · external' },
|
||||
],
|
||||
});
|
||||
properties.set('sid', 'no');
|
||||
properties.set('secondary-sid', false);
|
||||
const state = await runtime.getState();
|
||||
assert.equal(state.primary, null);
|
||||
assert.equal(state.secondary, null);
|
||||
});
|
||||
|
||||
test('subtitle selector swaps tracks and supports disabling both tracks', async () => {
|
||||
const { runtime, commands } = setup();
|
||||
await runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'secondary-sid', 'no'],
|
||||
['set_property', 'sid', 3],
|
||||
['set_property', 'secondary-sid', 2],
|
||||
]);
|
||||
commands.length = 0;
|
||||
await runtime.apply({ mediaPath: '/video.mkv', primary: null, secondary: null });
|
||||
assert.ok(commands.every((command) => command[2] === 'no'));
|
||||
});
|
||||
|
||||
test('subtitle selector rejects stale media, unavailable tracks, duplicate tracks and malformed requests without mutation', async () => {
|
||||
const { runtime, commands } = setup();
|
||||
for (const request of [
|
||||
{ mediaPath: '/other.mkv', primary: 2, secondary: 3 },
|
||||
{ mediaPath: '/video.mkv', primary: 99, secondary: null },
|
||||
{ mediaPath: '/video.mkv', primary: 2, secondary: 2 },
|
||||
{ mediaPath: '/video.mkv', primary: '2', secondary: null },
|
||||
{ mediaPath: '/video.mkv', primary: -1, secondary: null },
|
||||
null,
|
||||
])
|
||||
await assert.rejects(runtime.apply(request));
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('subtitle selector gates access on config and connection and propagates mpv failures', async () => {
|
||||
const { runtime, client, disable } = setup();
|
||||
client.request = async () => ({ error: 'property unavailable' });
|
||||
await assert.rejects(
|
||||
runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }),
|
||||
/property unavailable/,
|
||||
);
|
||||
client.connected = false;
|
||||
await assert.rejects(runtime.getState(), /Connect to mpv/);
|
||||
disable();
|
||||
await assert.rejects(runtime.getState(), /Enable subtitle selection/);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { IpcMain, WebContents } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseSubtitleSelectionRequest,
|
||||
type SubtitleSelectionState,
|
||||
} from '../../shared/subtitle-selection';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
interface SelectionMpvClient {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
request: (command: unknown[]) => Promise<{ error?: string }>;
|
||||
}
|
||||
|
||||
export function openSubtitleSelectionModal(
|
||||
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
|
||||
): Promise<boolean> {
|
||||
return retryOverlayModalOpen(deps, {
|
||||
modal: 'subtitle-selection',
|
||||
timeoutMs: 1500,
|
||||
retryWarning: 'Subtitle selection modal did not acknowledge opening; retrying.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(deps, {
|
||||
channel: IPC_CHANNELS.event.subtitleSelectionOpen,
|
||||
modal: 'subtitle-selection',
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSubtitleSelectionRuntime(deps: {
|
||||
isEnabled: () => boolean;
|
||||
getMpvClient: () => SelectionMpvClient | null;
|
||||
}) {
|
||||
function getClient(): SelectionMpvClient {
|
||||
if (!deps.isEnabled()) throw new Error('Enable subtitle selection in Settings first.');
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) throw new Error('Connect to mpv first.');
|
||||
return client;
|
||||
}
|
||||
|
||||
async function readState(client: SelectionMpvClient): Promise<SubtitleSelectionState> {
|
||||
const mediaPath = await client.requestProperty('path');
|
||||
if (typeof mediaPath !== 'string' || !mediaPath) throw new Error('Open a video first.');
|
||||
const [rawTracks, primary, secondary] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('secondary-sid'),
|
||||
]);
|
||||
const tracks: SubtitleSelectionState['tracks'] = [];
|
||||
const candidates: unknown[] = Array.isArray(rawTracks) ? rawTracks : [];
|
||||
for (const track of candidates) {
|
||||
if (
|
||||
typeof track !== 'object' ||
|
||||
track === null ||
|
||||
!('type' in track) ||
|
||||
track.type !== 'sub' ||
|
||||
!('id' in track) ||
|
||||
typeof track.id !== 'number' ||
|
||||
!Number.isSafeInteger(track.id) ||
|
||||
track.id <= 0
|
||||
)
|
||||
continue;
|
||||
const details = [
|
||||
'title' in track ? track.title : undefined,
|
||||
'lang' in track ? track.lang : undefined,
|
||||
'codec' in track ? track.codec : undefined,
|
||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
if ('external' in track && track.external === true) details.push('external');
|
||||
tracks.push({ id: track.id, label: `#${track.id} · ${details.join(' · ') || 'Subtitle'}` });
|
||||
}
|
||||
if ((await client.requestProperty('path')) !== mediaPath)
|
||||
throw new Error('The video changed. Reopen subtitle selection.');
|
||||
const selected = (value: unknown): number | null =>
|
||||
tracks.find((track) => track.id === value)?.id ?? null;
|
||||
return { mediaPath, tracks, primary: selected(primary), secondary: selected(secondary) };
|
||||
}
|
||||
|
||||
async function apply(value: unknown): Promise<void> {
|
||||
const selection = parseSubtitleSelectionRequest(value);
|
||||
const client = getClient();
|
||||
const current = await readState(client);
|
||||
if (current.mediaPath !== selection.mediaPath)
|
||||
throw new Error('The video changed. Reopen subtitle selection.');
|
||||
for (const id of [selection.primary, selection.secondary]) {
|
||||
if (id !== null && !current.tracks.some((track) => track.id === id))
|
||||
throw new Error('A selected track is no longer available. Reopen subtitle selection.');
|
||||
}
|
||||
const set = async (property: string, id: number | null): Promise<void> => {
|
||||
const response = await client.request(['set_property', property, id ?? 'no']);
|
||||
if (response.error && response.error !== 'success') throw new Error(response.error);
|
||||
};
|
||||
// Clear secondary first so swapping the two tracks works in mpv.
|
||||
await set('secondary-sid', null);
|
||||
await set('sid', selection.primary);
|
||||
await set('secondary-sid', selection.secondary);
|
||||
}
|
||||
|
||||
return { getState: async () => readState(getClient()), apply };
|
||||
}
|
||||
|
||||
export function registerSubtitleSelectionIpc(deps: {
|
||||
ipc: Pick<IpcMain, 'handle'>;
|
||||
isAllowedSender: (sender: WebContents) => boolean;
|
||||
runtime: ReturnType<typeof createSubtitleSelectionRuntime>;
|
||||
}): void {
|
||||
deps.ipc.handle(IPC_CHANNELS.request.getSubtitleSelection, (event) => {
|
||||
if (!deps.isAllowedSender(event.sender))
|
||||
throw new Error('Subtitle selection requires the overlay.');
|
||||
return deps.runtime.getState();
|
||||
});
|
||||
deps.ipc.handle(IPC_CHANNELS.request.applySubtitleSelection, (event, value: unknown) => {
|
||||
if (!deps.isAllowedSender(event.sender))
|
||||
throw new Error('Subtitle selection requires the overlay.');
|
||||
return deps.runtime.apply(value);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user