diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 934eec7..bed4f5e 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -38,6 +38,8 @@ These control playback and subtitle display. They require overlay window focus. | Shortcut | Action | | -------------------- | -------------------------------------------------- | | `Space` | Toggle mpv pause | +| `J` | Cycle primary subtitle track | +| `Shift+J` | Cycle secondary subtitle track | | `ArrowRight` | Seek forward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds | | `ArrowUp` | Seek forward 60 seconds | diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 254e41f..bf084e4 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -5,6 +5,7 @@ import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG, + DEFAULT_KEYBINDINGS, RUNTIME_OPTION_REGISTRY, } from '../definitions'; import { buildCoreConfigOptionRegistry } from './options-core'; @@ -59,3 +60,9 @@ test('domain registry builders each contribute entries to composed registry', () assert.ok(entries.some((entry) => composedPaths.has(entry.path))); } }); + +test('default keybindings include primary and secondary subtitle track cycling on J keys', () => { + const keybindingMap = new Map(DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command])); + assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']); + assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']); +}); diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index ad648a5..045cdaa 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -48,6 +48,8 @@ export const SPECIAL_COMMANDS = { export const DEFAULT_KEYBINDINGS: NonNullable = [ { key: 'Space', command: ['cycle', 'pause'] }, + { key: 'KeyJ', command: ['cycle', 'sid'] }, + { key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] }, { key: 'ArrowRight', command: ['seek', 5] }, { key: 'ArrowLeft', command: ['seek', -5] }, { key: 'ArrowUp', command: ['seek', 60] }, diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts new file mode 100644 index 0000000..ff54d15 --- /dev/null +++ b/src/core/services/ipc-command.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handleMpvCommandFromIpc } from './ipc-command'; + +function createOptions(overrides: Partial[1]> = {}) { + const calls: string[] = []; + const sentCommands: (string | number)[][] = []; + const osd: string[] = []; + const options: Parameters[1] = { + specialCommands: { + SUBSYNC_TRIGGER: '__subsync-trigger', + RUNTIME_OPTIONS_OPEN: '__runtime-options-open', + RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', + REPLAY_SUBTITLE: '__replay-subtitle', + PLAY_NEXT_SUBTITLE: '__play-next-subtitle', + }, + triggerSubsyncFromConfig: () => { + calls.push('subsync'); + }, + openRuntimeOptionsPalette: () => { + calls.push('runtime-options'); + }, + runtimeOptionsCycle: () => ({ ok: true }), + showMpvOsd: (text) => { + osd.push(text); + }, + mpvReplaySubtitle: () => { + calls.push('replay'); + }, + mpvPlayNextSubtitle: () => { + calls.push('next'); + }, + mpvSendCommand: (command) => { + sentCommands.push(command); + }, + isMpvConnected: () => true, + hasRuntimeOptionsManager: () => true, + ...overrides, + }; + return { options, calls, sentCommands, osd }; +} + +test('handleMpvCommandFromIpc forwards regular mpv commands', () => { + const { options, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['cycle', 'pause'], options); + assert.deepEqual(sentCommands, [['cycle', 'pause']]); + assert.deepEqual(osd, []); +}); + +test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', () => { + const { options, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['add', 'sub-pos', 1], options); + assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]); + assert.deepEqual(osd, ['Subtitle position: ${sub-pos}']); +}); + +test('handleMpvCommandFromIpc emits osd for primary subtitle track keybinding proxies', () => { + const { options, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['cycle', 'sid'], options); + assert.deepEqual(sentCommands, [['cycle', 'sid']]); + assert.deepEqual(osd, ['Subtitle track: ${sid}']); +}); + +test('handleMpvCommandFromIpc emits osd for secondary subtitle track keybinding proxies', () => { + const { options, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options); + assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]); + assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']); +}); + +test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { + const { options, sentCommands, osd } = createOptions({ + isMpvConnected: () => false, + }); + handleMpvCommandFromIpc(['add', 'sub-pos', 1], options); + assert.deepEqual(sentCommands, []); + assert.deepEqual(osd, []); +}); diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 347e33b..05dca5e 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -24,6 +24,31 @@ export interface HandleMpvCommandFromIpcOptions { hasRuntimeOptionsManager: () => boolean; } +const MPV_PROPERTY_COMMANDS = new Set([ + 'add', + 'set', + 'set_property', + 'cycle', + 'cycle-values', + 'multiply', +]); + +function resolveProxyCommandOsd(command: (string | number)[]): string | null { + const operation = typeof command[0] === 'string' ? command[0] : ''; + 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}'; + } + if (property === 'sid') { + return 'Subtitle track: ${sid}'; + } + if (property === 'secondary-sid') { + return 'Secondary subtitle track: ${secondary-sid}'; + } + return null; +} + export function handleMpvCommandFromIpc( command: (string | number)[], options: HandleMpvCommandFromIpcOptions, @@ -58,6 +83,10 @@ export function handleMpvCommandFromIpc( options.mpvPlayNextSubtitle(); } else { options.mpvSendCommand(command); + const osd = resolveProxyCommandOsd(command); + if (osd) { + options.showMpvOsd(osd); + } } } }