From 8bbe2120825e5ebb2a11778e0b93ecf2dd2b4cc6 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Thu, 15 Oct 2026 15:13:07 +0100 Subject: [PATCH] Ignore Windows pause commands so they can't stop the office speakers The mic-in-use check from #109 didn't stop the pauses: Bluetooth headsets send pause when they connect, switch to call mode or come off, which doesn't line up with the mic opening. Windows can't tell those apart from a deliberate media-key pause, so pause commands from SMTC and Chromium's media session are now ignored on Windows. Play, next and previous still work, and pausing from the app and its taskbar buttons is unchanged. Removes the now-unused mic-in-use module. Co-Authored-By: Claude Opus 5.5 --- renderer/src/hooks/useMediaSession.ts | 10 +++++- src/main.ts | 42 ++++------------------ src/mic-in-use.test.ts | 45 ----------------------- src/mic-in-use.ts | 52 --------------------------- 4 files changed, 16 insertions(+), 133 deletions(-) delete mode 100644 src/mic-in-use.test.ts delete mode 100644 src/mic-in-use.ts diff --git a/renderer/src/hooks/useMediaSession.ts b/renderer/src/hooks/useMediaSession.ts index 09978d8..f07b72c 100644 --- a/renderer/src/hooks/useMediaSession.ts +++ b/renderer/src/hooks/useMediaSession.ts @@ -123,7 +123,15 @@ export function useMediaSession(playback: PlaybackState) { const provider = () => getActiveProvider(); navigator.mediaSession.setActionHandler("play", () => { provider().play().catch(() => {}); }); - navigator.mediaSession.setActionHandler("pause", () => { provider().pause().catch(() => {}); }); + // On Windows, OS pause commands come from Teams joining a call and from + // Bluetooth headsets as well as the user, and would stop the shared + // speakers for everyone — so they're swallowed (a no-op handler, not null, + // which would let Chromium apply its default). See the SMTC handler in main.ts. + const ignoreOsPause = navigator.userAgent.includes("Windows"); + navigator.mediaSession.setActionHandler("pause", () => { + if (ignoreOsPause) return; + provider().pause().catch(() => {}); + }); navigator.mediaSession.setActionHandler("previoustrack", () => { provider().skipPrev().catch(() => {}); }); navigator.mediaSession.setActionHandler("nexttrack", () => { provider().skipNext().catch(() => {}); }); navigator.mediaSession.setActionHandler("seekto", (details) => { diff --git a/src/main.ts b/src/main.ts index 5a5f159..55d0df7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,6 @@ import WebSocket from 'ws'; import type { FetchRequest, FetchResponse } from './types'; import * as telemetry from './telemetry'; import * as smtc from './smtc'; -import { isMicInUse } from './mic-in-use'; // Ensure the renderer's Media Session API (used to surface "now playing" to // Windows System Media Transport Controls / macOS Now Playing) is wired up. @@ -50,7 +49,13 @@ smtc.init((action) => { wsSend({ namespace: 'playback', groupId, command: 'play' }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); break; case 'pause': - void handleSmtcPause(); + // Deliberately ignored. Windows sends pause for more than the user + // pressing pause: Teams pauses every media session on joining a call, + // and Bluetooth headsets send pause when they connect, switch to call + // mode or come off. Any of those would stop the shared office speakers + // for everyone, so pausing is left to the app's own controls. + console.log('[smtc] ignoring pause command from Windows'); + telemetry.event('SmtcPauseIgnored'); break; case 'next': wsSend({ namespace: 'playback', groupId, command: 'skipToNextTrack' }, {}).catch(() => {}); @@ -61,39 +66,6 @@ smtc.init((action) => { } }, { displayName: 'True Tunes', appPath: app.getAppPath(), isPackaged: app.isPackaged }); -/** How long after an SMTC pause we keep watching for a call starting. */ -const CALL_PAUSE_GRACE_MS = 4000; -const CALL_PAUSE_POLL_MS = 500; - -/** - * Teams (and other call apps) pause every SMTC media session when you join a - * call. That's right for local audio, but here it would pause the office - * speakers for everyone. The pause event doesn't say who sent it, so we use - * "another app has the mic open" as the call signal: if it already is, the - * pause is ignored; if it opens within a few seconds of us pausing, we resume. - */ -async function handleSmtcPause(): Promise { - if (await isMicInUse()) { - console.log('[smtc] ignoring pause: microphone in use (call in progress)'); - telemetry.event('SmtcPauseIgnored', { reason: 'mic-in-use' }); - return; - } - const sendPlayback = (command: 'play' | 'pause') => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - wsSend({ namespace: 'playback', groupId: config.groupId, command }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); - }; - sendPlayback('pause'); - for (let waited = 0; waited < CALL_PAUSE_GRACE_MS; waited += CALL_PAUSE_POLL_MS) { - await new Promise((r) => setTimeout(r, CALL_PAUSE_POLL_MS)); - if (await isMicInUse()) { - console.log('[smtc] call started right after pause, resuming playback'); - telemetry.event('SmtcPauseIgnored', { reason: 'mic-opened-after' }); - sendPlayback('play'); - return; - } - } -} - // ─── Config ────────────────────────────────────────────────────────────────── /** Music service credentials (persisted to userData/config.json) plus diff --git a/src/mic-in-use.test.ts b/src/mic-in-use.test.ts deleted file mode 100644 index 91011b0..0000000 --- a/src/mic-in-use.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Run: npm run test:main -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { parseMicUsage } from './mic-in-use.ts'; - -const ROOT = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'; - -const SAMPLE = [ - ROOT, - ' Value REG_SZ Allow', - '', - `${ROOT}\\Microsoft.Copilot_8wekyb3d8bbwe`, - ' Value REG_SZ Prompt', - ' LastSetTime REG_QWORD 0x1db9a44d9072d01', - '', - `${ROOT}\\MSTeams_8wekyb3d8bbwe`, - ' Value REG_SZ Allow', - ' LastUsedTimeStart REG_QWORD 0x1dd3ac0038f1caf', - ' LastUsedTimeStop REG_QWORD 0x0', - '', - `${ROOT}\\NonPackaged`, - '', - `${ROOT}\\NonPackaged\\C:#Program Files#Discord#Discord.exe`, - ' LastUsedTimeStart REG_QWORD 0x1dd3ac0038f1caf', - ' LastUsedTimeStop REG_QWORD 0x1dd3ac0038f2000', - '', -].join('\r\n'); - -test('reports apps with an open mic (start set, stop zero)', () => { - assert.deepEqual(parseMicUsage(SAMPLE), [`${ROOT}\\MSTeams_8wekyb3d8bbwe`]); -}); - -test('finished sessions and never-used apps are not in use', () => { - const idle = SAMPLE.replace('LastUsedTimeStop REG_QWORD 0x0', 'LastUsedTimeStop REG_QWORD 0x1dd3ac0038f2000'); - assert.deepEqual(parseMicUsage(idle), []); -}); - -test('in-use entry as the last key in the output is still reported', () => { - const out = `${ROOT}\\NonPackaged\\C:#zoom.exe\r\n LastUsedTimeStart REG_QWORD 0x1\r\n LastUsedTimeStop REG_QWORD 0x0`; - assert.deepEqual(parseMicUsage(out), [`${ROOT}\\NonPackaged\\C:#zoom.exe`]); -}); - -test('empty output', () => { - assert.deepEqual(parseMicUsage(''), []); -}); diff --git a/src/mic-in-use.ts b/src/mic-in-use.ts deleted file mode 100644 index 62280c2..0000000 --- a/src/mic-in-use.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Windows-only "is another app using the microphone right now?" check, used to -// tell a call app's automatic SMTC pause (Teams pauses every media session -// when you join a call) apart from a deliberate media-key / flyout pause. -// -// Windows records per-app microphone usage under the CapabilityAccessManager -// consent store (the same data behind the taskbar's "app is using your -// microphone" indicator). An app is currently using the mic when its key has -// a non-zero LastUsedTimeStart and LastUsedTimeStop == 0. Packaged apps (e.g. -// new Teams, MSTeams_8wekyb3d8bbwe) are direct subkeys; Win32 apps live under -// NonPackaged. Complete no-op (always false) off Windows or if `reg` fails. - -import { execFile } from 'child_process'; - -const MIC_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'; - -/** Parse `reg query /s` output into the list of keys currently using the mic. */ -export function parseMicUsage(output: string): string[] { - const active: string[] = []; - let key: string | null = null; - let start = 0n; - let stop: bigint | null = null; - const flush = () => { - if (key && start !== 0n && stop === 0n) active.push(key); - }; - for (const raw of output.split(/\r?\n/)) { - const line = raw.trim(); - if (line.startsWith('HKEY_')) { - flush(); - key = line; - start = 0n; - stop = null; - continue; - } - const m = /^(LastUsedTimeStart|LastUsedTimeStop)\s+REG_QWORD\s+(0x[0-9a-fA-F]+)$/.exec(line); - if (!m) continue; - if (m[1] === 'LastUsedTimeStart') start = BigInt(m[2]); - else stop = BigInt(m[2]); - } - flush(); - return active; -} - -/** Resolves true if any app currently has the microphone open. Never rejects. */ -export function isMicInUse(): Promise { - if (process.platform !== 'win32') return Promise.resolve(false); - return new Promise((resolve) => { - execFile('reg', ['query', MIC_KEY, '/s'], { windowsHide: true, timeout: 2000 }, (err, stdout) => { - if (err) return resolve(false); - resolve(parseMicUsage(stdout).length > 0); - }); - }); -}