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.
This commit is contained in:
2026-09-30 23:24:39 -07:00
committed by GitHub
parent 7b72dd2cc4
commit ffbbe698fc
10 changed files with 73 additions and 2 deletions
@@ -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.
+4 -1
View File
@@ -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 `handlers/mpv-input-forwarding.ts` keeps the session lookup, coalesces asynchronous
refreshes, and releases held keys on blur or disposal. `handlers/keyboard.ts` runs 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 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 plugin artifact, persistent config, or session help.
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey` The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
+2
View File
@@ -5354,6 +5354,8 @@ const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSession
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
onBindingsChanged: (bindings) => onBindingsChanged: (bindings) =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings), overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
onMpvInputBindingsChanged: () =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged),
onWarning: (warning) => { onWarning: (warning) => {
if (warning.kind !== 'conflict') return; if (warning.kind !== 'conflict') return;
overlayNotificationsRuntime.showOverlayNotification({ overlayNotificationsRuntime.showOverlayNotification({
@@ -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 () => { 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 root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
const sequence: CompiledSessionBinding = { const sequence: CompiledSessionBinding = {
+13 -1
View File
@@ -25,6 +25,10 @@ export interface SessionBindingsRuntimeDeps {
setSessionBindingsInitialized: (initialized: boolean) => void; setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string, details?: unknown) => void; logWarn: (message: string, details?: unknown) => void;
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => 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; onWarning?: (warning: SessionBindingWarning) => void;
} }
@@ -41,6 +45,7 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
let nativeSnapshot: { let nativeSnapshot: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>; client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
keys: string[]; keys: string[];
signature: string;
} | null = null; } | null = null;
let pending: { let pending: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>; client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
@@ -135,8 +140,15 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
try { try {
const raw = await client.requestProperty('input-bindings'); const raw = await client.requestProperty('input-bindings');
if (client !== deps.getMpvClient() || !client.connected) return; 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(); publishBindings();
if (changed) deps.onMpvInputBindingsChanged?.();
} catch { } catch {
// Keep the last successful snapshot if discovery is temporarily unavailable. // Keep the last successful snapshot if discovery is temporarily unavailable.
} }
+3
View File
@@ -624,6 +624,9 @@ const electronAPI: ElectronAPI = {
(_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings), (_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings),
); );
}, },
onMpvInputBindingsChanged: (callback: () => void) => {
ipcRenderer.on(IPC_CHANNELS.event.mpvInputBindingsChanged, () => callback());
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on( ipcRenderer.on(
IPC_CHANNELS.event.configHotReload, IPC_CHANNELS.event.configHotReload,
+3
View File
@@ -1335,6 +1335,9 @@ export function createKeyboardHandlers(
setupMpvInputForwarding, setupMpvInputForwarding,
refreshConfiguredShortcuts, refreshConfiguredShortcuts,
updateSessionBindings, updateSessionBindings,
refreshMpvInputBindings: () => {
void importedMpvBindings.refresh();
},
syncKeyboardTokenSelection, syncKeyboardTokenSelection,
handleSubtitleContentUpdated, handleSubtitleContentUpdated,
togglePrimarySubtitleBarVisibility, togglePrimarySubtitleBarVisibility,
+3
View File
@@ -779,6 +779,9 @@ async function init(): Promise<void> {
}); });
}); });
// 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(); await keyboardHandlers.setupMpvInputForwarding();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
+1
View File
@@ -181,6 +181,7 @@ export const IPC_CHANNELS = {
subtitleSidebarToggle: 'subtitle-sidebar:toggle', subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
sessionBindingsChanged: 'session-bindings:changed', sessionBindingsChanged: 'session-bindings:changed',
mpvInputBindingsChanged: 'mpv-input-bindings:changed',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification', overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle', notificationHistoryToggle: 'notification-history:toggle',
+1
View File
@@ -656,6 +656,7 @@ export interface ElectronAPI {
) => void; ) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void; onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
onMpvInputBindingsChanged: (callback: () => void) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
} }