From ffbbe698fc89fd8aed4e9786b3b6b8fda01449bb Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 30 Sep 2026 23:24:39 -0700 Subject: [PATCH] fix(overlay): reload imported mpv keys after mpv connects (#278) * fix(overlay): reload imported mpv keys after mpv connects The overlay fetched mpv's input-bindings before SubMiner connected to mpv, cached an empty list, and never refetched because the connect-time refresh only broadcast session bindings when their compiled signature changed. Keys from input.conf and mpv defaults (9/0 volume, m mute) then did nothing while the overlay had focus, most often in mpv.backend x11 mode. Main now emits mpv-input-bindings:changed whenever the discovered mpv key set or client changes, and the renderer refreshes its imported keys on it. * fix(overlay): subscribe to mpv key changes before initial discovery The renderer registered the mpv-input-bindings:changed listener only after awaiting setupMpvInputForwarding and several other startup calls. If mpv connected in that window, the event was dropped and the imported mpv keys stayed empty. --- changes/mpv-key-forwarding-late-connect.md | 4 ++ docs/architecture/domains.md | 5 ++- src/main.ts | 2 + .../runtime/session-bindings-runtime.test.ts | 39 +++++++++++++++++++ src/main/runtime/session-bindings-runtime.ts | 14 ++++++- src/preload.ts | 3 ++ src/renderer/handlers/keyboard.ts | 3 ++ src/renderer/renderer.ts | 3 ++ src/shared/ipc/contracts.ts | 1 + src/types/runtime.ts | 1 + 10 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 changes/mpv-key-forwarding-late-connect.md 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; }