diff --git a/changes/mpv-key-forwarding-late-connect.md b/changes/mpv-key-forwarding-late-connect.md new file mode 100644 index 00000000..2139bc82 --- /dev/null +++ b/changes/mpv-key-forwarding-late-connect.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index d5091148..9026d326 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -50,7 +50,10 @@ validates discovered keys and translates browser input. The renderer's `handlers/mpv-input-forwarding.ts` keeps the session lookup, coalesces asynchronous refreshes, and releases held keys on blur or disposal. `handlers/keyboard.ts` runs this fallback after SubMiner controls and refreshes on startup, a delayed startup -pass, focus, and binding reload. Discovery does not enter compiled session bindings, +pass, focus, binding reload, and the `mpv-input-bindings:changed` event. Main sends +that event from `session-bindings-runtime.ts` when mpv's discovered key set changes, +including the first discovery after connecting, because the overlay often loads +before mpv connects and the compiled session bindings may not change. Discovery does not enter compiled session bindings, the plugin artifact, persistent config, or session help. The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey` diff --git a/src/main.ts b/src/main.ts index 73f76ac0..61bd61c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5354,6 +5354,8 @@ const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSession logWarn: (message) => logger.warn(message), onBindingsChanged: (bindings) => overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings), + onMpvInputBindingsChanged: () => + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged), onWarning: (warning) => { if (warning.kind !== 'conflict') return; overlayNotificationsRuntime.showOverlayNotification({ diff --git a/src/main/runtime/session-bindings-runtime.test.ts b/src/main/runtime/session-bindings-runtime.test.ts index b6785b00..7868cfbf 100644 --- a/src/main/runtime/session-bindings-runtime.test.ts +++ b/src/main/runtime/session-bindings-runtime.test.ts @@ -71,6 +71,45 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f } }); +test('mpv input binding discovery notifies the overlay when the native key set changes', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-native-keys-')); + let nativeKeys: unknown = [{ key: '9', cmd: 'add volume -5', priority: 25 }]; + let notifications = 0; + const client = { + connected: false, + send: () => {}, + requestProperty: async () => nativeKeys, + }; + const runtime = createSessionBindingsRuntime({ + configDir: root, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never, + getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig, + getMpvClient: () => client, + setSessionBindings: () => {}, + setSessionBindingsInitialized: () => {}, + logWarn: () => {}, + onMpvInputBindingsChanged: () => { + notifications += 1; + }, + }); + try { + runtime.persistSessionBindings([]); + await runtime.refreshMpvSessionBindings(); + assert.equal(notifications, 0, 'nothing to announce before mpv connects'); + client.connected = true; + await runtime.refreshMpvSessionBindings(); + assert.equal(notifications, 1, 'first discovery after connecting must reach the overlay'); + await runtime.refreshMpvSessionBindings(); + assert.equal(notifications, 1, 'unchanged discovery must not create a refresh loop'); + nativeKeys = [{ key: '0', cmd: 'ignore', priority: 25 }]; + await runtime.refreshMpvSessionBindings(); + assert.equal(notifications, 2, 'ignored keys still change what the overlay may forward'); + } finally { + 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 = { diff --git a/src/main/runtime/session-bindings-runtime.ts b/src/main/runtime/session-bindings-runtime.ts index 8912d632..e861b76a 100644 --- a/src/main/runtime/session-bindings-runtime.ts +++ b/src/main/runtime/session-bindings-runtime.ts @@ -25,6 +25,10 @@ export interface SessionBindingsRuntimeDeps { setSessionBindingsInitialized: (initialized: boolean) => void; logWarn: (message: string, details?: unknown) => void; onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void; + // Fires when mpv's own key bindings change, including the first discovery after + // connecting. The overlay's imported mpv keys depend on them even when the + // compiled session bindings stay identical. + onMpvInputBindingsChanged?: () => void; onWarning?: (warning: SessionBindingWarning) => void; } @@ -41,6 +45,7 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): let nativeSnapshot: { client: ReturnType; keys: string[]; + signature: string; } | null = null; let pending: { client: ReturnType; @@ -135,8 +140,15 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): try { const raw = await client.requestProperty('input-bindings'); if (client !== deps.getMpvClient() || !client.connected) return; - nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) }; + const signature = JSON.stringify(parseMpvInputBindingKeys(raw)); + const changed = nativeSnapshot?.client !== client || nativeSnapshot.signature !== signature; + nativeSnapshot = { + client, + keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }), + signature, + }; publishBindings(); + if (changed) deps.onMpvInputBindingsChanged?.(); } catch { // Keep the last successful snapshot if discovery is temporarily unavailable. } diff --git a/src/preload.ts b/src/preload.ts index df99d0a3..c05b180d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -624,6 +624,9 @@ const electronAPI: ElectronAPI = { (_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings), ); }, + onMpvInputBindingsChanged: (callback: () => void) => { + ipcRenderer.on(IPC_CHANNELS.event.mpvInputBindingsChanged, () => callback()); + }, onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { ipcRenderer.on( IPC_CHANNELS.event.configHotReload, diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index aea79ec5..42b444dc 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -1335,6 +1335,9 @@ export function createKeyboardHandlers( setupMpvInputForwarding, refreshConfiguredShortcuts, updateSessionBindings, + refreshMpvInputBindings: () => { + void importedMpvBindings.refresh(); + }, syncKeyboardTokenSelection, handleSubtitleContentUpdated, togglePrimarySubtitleBarVisibility, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index c52e593a..eec6e991 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -779,6 +779,9 @@ async function init(): Promise { }); }); + // Subscribe before the initial discovery: mpv can connect while it is in flight, + // and a missed change event leaves the imported mpv keys empty. + window.electronAPI.onMpvInputBindingsChanged(keyboardHandlers.refreshMpvInputBindings); await keyboardHandlers.setupMpvInputForwarding(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index b1a929a1..00528f8a 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -181,6 +181,7 @@ export const IPC_CHANNELS = { subtitleSidebarToggle: 'subtitle-sidebar:toggle', primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', sessionBindingsChanged: 'session-bindings:changed', + mpvInputBindingsChanged: 'mpv-input-bindings:changed', configHotReload: 'config:hot-reload', overlayNotification: 'overlay:notification', notificationHistoryToggle: 'notification-history:toggle', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index ae886205..41f9598a 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -656,6 +656,7 @@ export interface ElectronAPI { ) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void; + onMpvInputBindingsChanged: (callback: () => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; }