mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 15:13:32 -07:00
Replace subtitle delay actions with native mpv keybindings (#120)
This commit is contained in:
@@ -101,8 +101,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--toggle-primary-subtitle-bar',
|
||||
'--replay-current-subtitle',
|
||||
'--play-next-subtitle',
|
||||
'--shift-sub-delay-prev-line',
|
||||
'--shift-sub-delay-next-line',
|
||||
'--cycle-runtime-option',
|
||||
'anki.autoUpdateNewCards:prev',
|
||||
'--session-action',
|
||||
@@ -120,8 +118,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
assert.equal(args.replayCurrentSubtitle, true);
|
||||
assert.equal(args.playNextSubtitle, true);
|
||||
assert.equal(args.shiftSubDelayPrevLine, true);
|
||||
assert.equal(args.shiftSubDelayNextLine, true);
|
||||
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
||||
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
|
||||
@@ -131,6 +127,13 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores retired subtitle delay shift flags', () => {
|
||||
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
|
||||
|
||||
assert.equal(hasExplicitCommand(args), false);
|
||||
assert.equal(shouldStartApp(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs captures internal playback feedback command', () => {
|
||||
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ export interface CliArgs {
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
playbackFeedback?: string;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
@@ -149,8 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
@@ -296,8 +292,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||
else if (arg.startsWith('--playback-feedback=')) {
|
||||
const value = arg.slice('--playback-feedback='.length).trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
@@ -562,8 +556,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
@@ -638,8 +630,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
@@ -705,8 +695,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
@@ -766,8 +754,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
@@ -832,8 +818,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
|
||||
@@ -234,3 +234,16 @@ test('default keybindings include replay and next subtitle controls', () => {
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
||||
});
|
||||
|
||||
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
|
||||
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
|
||||
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
|
||||
});
|
||||
|
||||
@@ -55,8 +55,6 @@ export const SPECIAL_COMMANDS = {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
} as const;
|
||||
@@ -72,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
{ key: 'ArrowDown', command: ['seek', -60] },
|
||||
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
||||
{ key: 'Shift+KeyL', command: ['sub-seek', 1] },
|
||||
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] },
|
||||
{
|
||||
key: 'Shift+BracketLeft',
|
||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
||||
},
|
||||
{ key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
|
||||
{ key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
|
||||
{ key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
|
||||
{ key: 'Shift+KeyZ', command: ['add', 'sub-delay', 0.1] },
|
||||
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
|
||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
|
||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -49,8 +49,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
|
||||
@@ -537,18 +537,6 @@ export function handleCliCommand(
|
||||
'playNextSubtitle',
|
||||
'Play next subtitle failed',
|
||||
);
|
||||
} else if (args.shiftSubDelayPrevLine) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'shiftSubDelayPrevLine' },
|
||||
'shiftSubDelayPrevLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.shiftSubDelayNextLine) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'shiftSubDelayNextLine' },
|
||||
'shiftSubDelayNextLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.playbackFeedback) {
|
||||
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
||||
showFeedback(args.playbackFeedback);
|
||||
|
||||
@@ -10,7 +10,6 @@ export {
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from './overlay-shortcut';
|
||||
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
||||
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
||||
export {
|
||||
copyCurrentSubtitle,
|
||||
|
||||
@@ -15,8 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
},
|
||||
@@ -48,9 +46,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
mpvPlayNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
mpvSendCommand: (command) => {
|
||||
sentCommands.push(command);
|
||||
},
|
||||
@@ -111,20 +106,29 @@ test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle tra
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => {
|
||||
test('handleMpvCommandFromIpc emits mpv OSD for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(playbackFeedback, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
||||
test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['sub-step', 1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['sub-step', 1]]);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(playbackFeedback, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
||||
assert.deepEqual(calls, ['shift:next']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||
REPLAY_SUBTITLE: string;
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||
YOUTUBE_PICKER_OPEN: string;
|
||||
PLAYLIST_BROWSER_OPEN: string;
|
||||
};
|
||||
@@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showRawMpvOsd?: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
mpvSendCommand: (command: (string | number)[]) => void;
|
||||
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([
|
||||
'multiply',
|
||||
]);
|
||||
|
||||
function resolveProxyCommandOsdTemplate(command: (string | number)[]): string | null {
|
||||
interface ProxyCommandFeedback {
|
||||
template: string;
|
||||
rawMpvOsd: boolean;
|
||||
}
|
||||
|
||||
function resolveProxyCommandOsdTemplate(command: (string | number)[]): ProxyCommandFeedback | null {
|
||||
const operation = typeof command[0] === 'string' ? command[0] : '';
|
||||
if (operation === 'sub-step') {
|
||||
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
|
||||
}
|
||||
|
||||
const property = typeof command[1] === 'string' ? command[1] : '';
|
||||
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
|
||||
if (property === 'sub-pos') {
|
||||
return 'Subtitle position: ${sub-pos}';
|
||||
return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false };
|
||||
}
|
||||
if (property === 'sid') {
|
||||
return 'Subtitle track: ${sid}';
|
||||
return { template: 'Subtitle track: ${sid}', rawMpvOsd: false };
|
||||
}
|
||||
if (property === 'secondary-sid') {
|
||||
return 'Secondary subtitle track: ${secondary-sid}';
|
||||
return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false };
|
||||
}
|
||||
if (property === 'sub-delay') {
|
||||
return 'Subtitle delay: ${sub-delay}';
|
||||
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd(
|
||||
command: (string | number)[],
|
||||
options: HandleMpvCommandFromIpcOptions,
|
||||
): void {
|
||||
const template = resolveProxyCommandOsdTemplate(command);
|
||||
if (!template) return;
|
||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
||||
const feedback = resolveProxyCommandOsdTemplate(command);
|
||||
if (!feedback) return;
|
||||
const showFeedback = feedback.rawMpvOsd
|
||||
? (options.showRawMpvOsd ?? options.showMpvOsd)
|
||||
: (options.showPlaybackFeedback ?? options.showMpvOsd);
|
||||
|
||||
const emit = async () => {
|
||||
try {
|
||||
const resolved = await options.resolveProxyCommandOsd?.(command);
|
||||
showFeedback(resolved || template);
|
||||
showFeedback(resolved || feedback.template);
|
||||
} catch {
|
||||
showFeedback(template);
|
||||
showFeedback(feedback.template);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,20 +127,6 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
||||
) {
|
||||
const direction =
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START
|
||||
? 'next'
|
||||
: 'previous';
|
||||
options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => {
|
||||
options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (!options.hasRuntimeOptionsManager()) return;
|
||||
const [, idToken, directionToken] = first.split(':');
|
||||
|
||||
@@ -47,9 +47,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
},
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('play-next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps {
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
playNextPlaylistItem: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
@@ -124,12 +123,6 @@ export async function dispatchSessionAction(
|
||||
case 'playNextSubtitle':
|
||||
deps.playNextSubtitle();
|
||||
return;
|
||||
case 'shiftSubDelayPrevLine':
|
||||
await deps.shiftSubDelayToAdjacentSubtitle('previous');
|
||||
return;
|
||||
case 'shiftSubDelayNextLine':
|
||||
await deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||
return;
|
||||
case 'cycleRuntimeOption': {
|
||||
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
|
||||
if (!runtimeOptionId) {
|
||||
|
||||
@@ -287,8 +287,6 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
|
||||
|
||||
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
||||
const expectedSpecialActions: Record<string, string> = {
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
|
||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
||||
@@ -320,6 +318,29 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
|
||||
}
|
||||
});
|
||||
|
||||
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [
|
||||
createKeybinding('Shift+BracketLeft', ['__sub-delay-prev-line']),
|
||||
createKeybinding('Shift+BracketRight', ['__sub-delay-next-line']),
|
||||
],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(
|
||||
result.bindings.map((binding) => ({
|
||||
actionType: binding.actionType,
|
||||
command: binding.actionType === 'mpv-command' ? binding.command : undefined,
|
||||
})),
|
||||
[
|
||||
{ actionType: 'mpv-command', command: ['__sub-delay-prev-line'] },
|
||||
{ actionType: 'mpv-command', command: ['__sub-delay-next-line'] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings omits disabled bindings', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
|
||||
@@ -319,14 +319,6 @@ function resolveCommandBinding(
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (command.length !== 1) {
|
||||
return null;
|
||||
|
||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
|
||||
function createMpvClient(props: Record<string, unknown>) {
|
||||
return {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => props[name],
|
||||
};
|
||||
}
|
||||
|
||||
test('shift subtitle delay to next cue using active external srt track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const osd: string[] = [];
|
||||
let loadCount = 0;
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => {
|
||||
loadCount += 1;
|
||||
return `1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-2
|
||||
|
||||
3
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-3`;
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
await handler('next');
|
||||
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(commands.length, 2);
|
||||
const delta = commands[0]?.[2];
|
||||
assert.equal(commands[0]?.[0], 'add');
|
||||
assert.equal(commands[0]?.[1], 'sub-delay');
|
||||
assert.equal(typeof delta, 'number');
|
||||
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
test('shift subtitle delay to previous cue using active external ass track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 4,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.ass',
|
||||
},
|
||||
],
|
||||
sid: 4,
|
||||
'sub-start': 2.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `[Events]
|
||||
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
|
||||
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
|
||||
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler('previous');
|
||||
|
||||
const delta = commands[0]?.[2];
|
||||
assert.equal(typeof delta, 'number');
|
||||
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||
});
|
||||
|
||||
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
|
||||
const shiftedDelays: number[] = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
'sub-delay': 0.5,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `1
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-2`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
|
||||
assert.deepEqual(shiftedDelays, [2.5]);
|
||||
});
|
||||
|
||||
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.vtt',
|
||||
},
|
||||
],
|
||||
sid: 1,
|
||||
'sub-start': 5.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `WEBVTT
|
||||
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
line-1
|
||||
|
||||
00:00:03.000 --> 00:00:04.000
|
||||
line-2
|
||||
|
||||
00:00:05.000 --> 00:00:06.000
|
||||
line-3`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
type SubtitleDelayShiftDirection = 'next' | 'previous';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
external?: unknown;
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
type SubtitleCueCacheEntry = {
|
||||
starts: number[];
|
||||
};
|
||||
|
||||
type SubtitleDelayShiftDeps = {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
onSubtitleDelayShifted?: (delaySeconds: number) => void;
|
||||
};
|
||||
|
||||
function asTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
if (Number.isInteger(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSrtOrVttStartTimes(content: string): number[] {
|
||||
const starts: number[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(
|
||||
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const hours = Number(match[1] || 0);
|
||||
const minutes = Number(match[2] || 0);
|
||||
const seconds = Number(match[3] || 0);
|
||||
const millis = Number(String(match[4]).padEnd(3, '0'));
|
||||
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function parseAssStartTimes(content: string): number[] {
|
||||
const starts: number[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(
|
||||
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
|
||||
if (secondsRaw === undefined) continue;
|
||||
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
|
||||
const hours = Number(hoursRaw);
|
||||
const minutes = Number(minutesRaw);
|
||||
const wholeSeconds = Number(wholeSecondsRaw);
|
||||
const fraction = Number(`0.${fractionRaw}`);
|
||||
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function normalizeCueStarts(starts: number[]): number[] {
|
||||
const sorted = starts
|
||||
.filter((value) => Number.isFinite(value) && value >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const deduped: number[] = [sorted[0]!];
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const current = sorted[i]!;
|
||||
const previous = deduped[deduped.length - 1]!;
|
||||
if (Math.abs(current - previous) > 0.0005) {
|
||||
deduped.push(current);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function parseCueStarts(content: string, source: string): number[] {
|
||||
const normalizedSource = source.toLowerCase().split('?')[0] || '';
|
||||
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
|
||||
const parseAssLike = () => parseAssStartTimes(content);
|
||||
|
||||
let starts: number[] = [];
|
||||
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
|
||||
starts = parseAssLike();
|
||||
if (starts.length === 0) {
|
||||
starts = parseSrtLike();
|
||||
}
|
||||
} else {
|
||||
starts = parseSrtLike();
|
||||
if (starts.length === 0) {
|
||||
starts = parseAssLike();
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeCueStarts(starts);
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
|
||||
const sid = asTrackId(sidRaw);
|
||||
if (sid === null) {
|
||||
throw new Error('No active subtitle track selected.');
|
||||
}
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
throw new Error('Could not inspect subtitle track list.');
|
||||
}
|
||||
|
||||
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const track = entry as MpvSubtitleTrackLike;
|
||||
return track.type === 'sub' && asTrackId(track.id) === sid;
|
||||
});
|
||||
|
||||
if (!activeTrack) {
|
||||
throw new Error('No active subtitle track found in mpv track list.');
|
||||
}
|
||||
if (activeTrack.external !== true) {
|
||||
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
|
||||
}
|
||||
|
||||
const source =
|
||||
typeof activeTrack['external-filename'] === 'string'
|
||||
? activeTrack['external-filename'].trim()
|
||||
: '';
|
||||
if (!source) {
|
||||
throw new Error('Active subtitle track has no external subtitle source path.');
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function findAdjacentCueStart(
|
||||
starts: number[],
|
||||
currentStart: number,
|
||||
direction: SubtitleDelayShiftDirection,
|
||||
): number {
|
||||
const epsilon = 0.0005;
|
||||
if (direction === 'next') {
|
||||
const target = starts.find((value) => value > currentStart + epsilon);
|
||||
if (target === undefined) {
|
||||
throw new Error('No next subtitle cue found for active subtitle source.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
for (let index = starts.length - 1; index >= 0; index -= 1) {
|
||||
const value = starts[index]!;
|
||||
if (value < currentStart - epsilon) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error('No previous subtitle cue found for active subtitle source.');
|
||||
}
|
||||
|
||||
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
|
||||
const cueCache = new Map<string, SubtitleCueCacheEntry>();
|
||||
|
||||
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client || !client.connected) {
|
||||
throw new Error('MPV not connected.');
|
||||
}
|
||||
|
||||
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('sub-start'),
|
||||
client.requestProperty('sub-delay'),
|
||||
]);
|
||||
|
||||
const currentStart =
|
||||
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
|
||||
if (currentStart === null) {
|
||||
throw new Error('Current subtitle start time is unavailable.');
|
||||
}
|
||||
|
||||
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
|
||||
let cueStarts = cueCache.get(source)?.starts;
|
||||
if (!cueStarts) {
|
||||
const content = await deps.loadSubtitleSourceText(source);
|
||||
cueStarts = parseCueStarts(content, source);
|
||||
cueCache.set(source, { starts: cueStarts });
|
||||
}
|
||||
|
||||
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||
const delta = targetStart - currentStart;
|
||||
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||
const currentDelay =
|
||||
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
|
||||
try {
|
||||
deps.onSubtitleDelayShifted?.(currentDelay + delta);
|
||||
} catch {}
|
||||
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||
};
|
||||
}
|
||||
+1
-25
@@ -348,7 +348,6 @@ import {
|
||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||
createConfigHotReloadRuntime,
|
||||
createDiscordPresenceService,
|
||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
||||
createFieldGroupingOverlayRuntime,
|
||||
createOverlayContentMeasurementStore,
|
||||
createOverlayManager,
|
||||
@@ -6838,26 +6837,6 @@ async function extractInternalSubtitleTrackToTempFile(
|
||||
};
|
||||
}
|
||||
|
||||
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
loadSubtitleSourceText,
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
onSubtitleDelayShifted: (delaySeconds) => {
|
||||
const key = activeJellyfinSubtitleDelayKey;
|
||||
if (!key) return;
|
||||
const saved = saveJellyfinSubtitleDelay({
|
||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
||||
itemId: key.itemId,
|
||||
streamIndex: key.streamIndex,
|
||||
delaySeconds,
|
||||
});
|
||||
if (!saved) {
|
||||
logger.warn('Failed to save Jellyfin subtitle delay.');
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
|
||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||
await dispatchSessionActionCore(request, {
|
||||
toggleStatsOverlay: () =>
|
||||
@@ -6905,8 +6884,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
@@ -6944,11 +6921,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
);
|
||||
},
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
showRawMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
||||
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
|
||||
@@ -226,10 +226,10 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
|
||||
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
|
||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||
@@ -424,10 +424,10 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
showRawMpvOsd: params.showRawMpvOsd,
|
||||
showPlaybackFeedback: params.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||
mpvSendCommand: params.mpvSendCommand,
|
||||
resolveProxyCommandOsd: params.resolveProxyCommandOsd,
|
||||
isMpvConnected: params.isMpvConnected,
|
||||
|
||||
@@ -17,10 +17,10 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showRawMpvOsd?: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
getMpvClient: () => MpvPropertyClientLike | null;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -42,11 +42,10 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
showRawMpvOsd: deps.showRawMpvOsd,
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
mpvSendCommand: deps.sendMpvCommand,
|
||||
resolveProxyCommandOsd: (nextCommand) =>
|
||||
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
|
||||
|
||||
@@ -17,7 +17,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => false,
|
||||
|
||||
@@ -58,8 +58,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -101,8 +101,6 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
|
||||
@@ -20,7 +20,6 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => true,
|
||||
|
||||
@@ -17,7 +17,6 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => true,
|
||||
|
||||
@@ -16,12 +16,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
|
||||
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
|
||||
getMpvClient: () => ({ connected: true, requestProperty: async () => null }),
|
||||
isMpvConnected: () => true,
|
||||
@@ -35,10 +33,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
void deps.openPlaylistBrowser();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
deps.showMpvOsd('hello');
|
||||
deps.showRawMpvOsd?.('delay');
|
||||
deps.showPlaybackFeedback?.('primary');
|
||||
deps.replayCurrentSubtitle();
|
||||
deps.playNextSubtitle();
|
||||
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||
deps.sendMpvCommand(['show-text', 'ok']);
|
||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||
assert.equal(deps.isMpvConnected(), true);
|
||||
@@ -50,10 +48,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'youtube-picker',
|
||||
'playlist-browser',
|
||||
'osd:hello',
|
||||
'raw-osd:delay',
|
||||
'feedback:primary',
|
||||
'replay',
|
||||
'next',
|
||||
'shift:next',
|
||||
'cmd:show-text:ok',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
) {
|
||||
return (): MpvCommandFromIpcRuntimeDeps => {
|
||||
const showPlaybackFeedback = deps.showPlaybackFeedback;
|
||||
const showRawMpvOsd = deps.showRawMpvOsd;
|
||||
return {
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
@@ -13,13 +14,12 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}),
|
||||
...(showPlaybackFeedback
|
||||
? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) }
|
||||
: {}),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
|
||||
@@ -980,8 +980,6 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
||||
test('default keybindings dispatch through overlay keyboard handling', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const specialActionIds: Record<string, string> = {
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
|
||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
||||
|
||||
@@ -96,18 +96,17 @@ function describeCommand(command: (string | number)[]): string {
|
||||
if (command[1] < 0) return 'Jump to previous subtitle';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === 'sub-step' && typeof command[1] === 'number') {
|
||||
if (command[1] > 0) return 'Shift subtitle delay to next cue';
|
||||
if (command[1] < 0) return 'Shift subtitle delay to previous cue';
|
||||
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'} ${
|
||||
@@ -131,6 +130,7 @@ function sectionForCommand(command: (string | number)[]): string {
|
||||
first === 'cycle' ||
|
||||
first === 'seek' ||
|
||||
first === 'sub-seek' ||
|
||||
first === 'sub-step' ||
|
||||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
|
||||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
|
||||
) {
|
||||
@@ -227,10 +227,6 @@ function describeSessionAction(
|
||||
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'
|
||||
@@ -271,8 +267,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
return 'Modals and tools';
|
||||
case 'replayCurrentSubtitle':
|
||||
case 'playNextSubtitle':
|
||||
case 'shiftSubDelayPrevLine':
|
||||
case 'shiftSubDelayNextLine':
|
||||
return 'Playback and navigation';
|
||||
case 'cycleRuntimeOption':
|
||||
return 'Runtime settings';
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
||||
import { createRendererState } from '../state.js';
|
||||
import {
|
||||
buildSessionHelpSections,
|
||||
@@ -17,13 +16,10 @@ test('session help describes sub-seek commands as subtitle-line navigation', ()
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
|
||||
});
|
||||
|
||||
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => {
|
||||
test('session help describes native subtitle-delay step commands separately from sub-seek', () => {
|
||||
assert.equal(describeSessionHelpCommand(['sub-step', 1]), 'Shift subtitle delay to next cue');
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]),
|
||||
'Shift subtitle delay to next cue',
|
||||
);
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
|
||||
describeSessionHelpCommand(['sub-step', -1]),
|
||||
'Shift subtitle delay to previous cue',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,8 +43,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openPlaylistBrowser',
|
||||
'replayCurrentSubtitle',
|
||||
'playNextSubtitle',
|
||||
'shiftSubDelayPrevLine',
|
||||
'shiftSubDelayNextLine',
|
||||
'cycleRuntimeOption',
|
||||
];
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ export type SessionActionId =
|
||||
| 'openPlaylistBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
| 'playNextSubtitle'
|
||||
| 'shiftSubDelayPrevLine'
|
||||
| 'shiftSubDelayNextLine'
|
||||
| 'cycleRuntimeOption';
|
||||
|
||||
export interface SessionKeySpec {
|
||||
|
||||
Reference in New Issue
Block a user