From 943af0c6ecac2997ac9f708cbdb65766ab107dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 10:42:34 +0000 Subject: [PATCH 1/2] feat: add audio input device reporting and selection A phone controller connected to a car often captures through the car's Bluetooth microphone, which takes the audio focus away and makes the platform pause the session. To let the platform offer another microphone instead, the API gains a vocabulary for audio inputs: - setAudioInputDevices(devices, activeDeviceId) reports the controller's audio inputs and the device the current stream uses. It accepts the result of enumerateDevices() as is and reduces each entry to { deviceId, label, groupId }, since nothing else survives postMessage cloning usefully. - onAudioInputDeviceChange(device_id) is called when the player picked another microphone on the platform, so the game can reopen its stream on it. - getUserMedia also sends microphoneRequested, which the platform correlates with the audio focus it loses. setAudioInputDevice is handled before the mediaPermissionPending_ guard in the inbound event handler: that guard drops stale messages of a settled permission request, and device selection happens long after, with no request pending. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- ARCHITECTURE.md | 75 ++++++ CHANGELOG.md | 9 + airconsole-1.11.0.js | 93 ++++++++ beta/airconsole-1.11.1.js | 93 ++++++++ tests/airconsole-1.11.0-spec.html | 1 + tests/airconsole-1.11.1-spec.html | 1 + tests/spec/airconsole-1.11.0-spec.js | 13 + .../spec/methods/spec-audio-input-devices.js | 224 ++++++++++++++++++ 8 files changed, 509 insertions(+) create mode 100644 tests/spec/methods/spec-audio-input-devices.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a2c80fc..878a400 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,3 +102,78 @@ Callback storage lives in a `WeakMap`, which keeps resolve and reject handlers a The 30 second timeout is a safety net. It rejects with `AirConsoleUserMediaError.timeout` if the platform never answers. `mediaPermissionPending_` blocks duplicate requests and ignores stale platform messages after cleanup. + +# Audio Input Device Selection + +## Why + +A phone controller connected to a car often captures through the car's Bluetooth hands-free microphone. Opening that +stream activates the hands-free profile, the car's media session loses audio focus, and the platform pauses the +session. The player can not act on that pause, so instead the platform offers them the other audio inputs of their +phone and asks the game to capture from the one they pick. + +The platform decides on the controller side: it can not observe the car's audio focus itself, so it correlates an +audio-focus-loss pause with recent microphone activity on that controller. + +## Actors + +**Game** reports its audio inputs and implements `onAudioInputDeviceChange` to swap streams. + +**API** forwards the reported devices to the platform and delivers the platform's selection to the game. + +**Platform** decides whether a pause was caused by the microphone and, if so, shows the audio input selection instead +of the pause overlay. + +## Sequence + +```mermaid +sequenceDiagram + autonumber + actor Game + participant API + participant Platform + participant Browser + + Game->>API: getUserMedia({ audio: true }) + API->>Platform: sendEvent_('requestUserMediaPermission', { constraints }) + API->>Platform: sendEvent_('microphoneRequested', {}) + Note over Platform: arm the audio focus loss window + Note over API,Browser: media permission flow (see above) + Browser-->>API: stream + API->>Platform: sendEvent_('userMediaPermissionGranted', { constraints }) + Note over Platform: re-arm the window, the microphone is now engaged + API-->>Game: Promise resolves with stream + + Game->>Browser: enumerateDevices() + Browser-->>Game: MediaDeviceInfo[] + Game->>API: setAudioInputDevices(devices, activeDeviceId) + Note over API: keep audioinput entries with a deviceId,
map to { deviceId, label, groupId } + API->>Platform: sendEvent_('audioInputDevicesReported', { devices, activeDeviceId }) + + Platform->>Platform: audio focus lost inside the window + Platform->>Platform: show audio input selection instead of the pause overlay + Platform->>API: event setAudioInputDevice { deviceId } + API-->>Game: onAudioInputDeviceChange(deviceId) + Game->>Browser: stop tracks, getUserMedia({ audio: { deviceId: { exact } } }) + Browser-->>Game: new stream + Game->>API: setAudioInputDevices(devices, deviceId) + API->>Platform: sendEvent_('audioInputDevicesReported', ...) +``` + +## Key Design Decisions + +`setAudioInputDevices` accepts the result of `enumerateDevices()` unchanged. It keeps entries whose `kind` is +`audioinput` (or that carry no `kind` at all, so plain objects can be reported too) and that have a `deviceId`, and +reduces each to `{ deviceId, label, groupId }`. Nothing else about a `MediaDeviceInfo` is useful to the platform, and +`toJSON` output would not survive `postMessage` cloning in every browser. + +`deviceId` values are scoped to the origin that enumerated them, so the platform treats them as opaque: it renders the +`label` and hands the `deviceId` back unchanged. It never enumerates devices itself, because the ids it would get do +not match the game's. + +`setAudioInputDevice` is handled before the `mediaPermissionPending_` guard in the inbound event handler. That guard +exists to drop stale messages of a permission request that already settled, and audio input selection happens long +after that, with no request pending. + +Reporting an empty list is how a game says the microphone is no longer in use. The platform then has nothing to offer +and falls back to its normal pause handling. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2595b..b5b67ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ ### Added +- New `AirConsole.setAudioInputDevices(devices, activeDeviceId)` for controllers to report their audio inputs and the + device the current microphone stream uses. +- New `AirConsole.onAudioInputDeviceChange(device_id)` callback, called when the player picked a different microphone on + the platform. The game closes its current stream and opens a new one on the given device. + - Together these let the platform offer a different microphone instead of pausing when opening the microphone takes + the audio focus away, e.g. a phone controller connected to a car capturing through the car's Bluetooth microphone. +- `getUserMedia` now also informs the platform that a microphone was requested, which the platform correlates with the + audio focus it loses. + ## [1.11.0] - 2026-07-07 ### Added diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index eb43da0..a3ced4f 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -875,6 +875,12 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { // Send the request to the platform to decide where and how the user media request needs to take place based on // browser or controller environment. me.sendEvent_('requestUserMediaPermission', { constraints: constraints }); + // Let the platform know a microphone is being requested. The platform uses this to recognize situations where + // opening the microphone is what takes the audio focus away from the platform (E.g. a phone controller capturing + // through a car's Bluetooth microphone), so it can offer the player another audio input instead of pausing. + if (constraints.audio) { + me.sendEvent_('microphoneRequested', {}); + } }); }; @@ -899,6 +905,86 @@ AirConsole.prototype.rejectMediaPermission_ = function rejectMediaPermission_(er if (cb) { cb.reject(error); } } +/** + * @typedef {Object} AirConsole~AudioInputDevice + * @property {string} deviceId - Identifier of the audio input, as reported by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices enumerateDevices}. + * @property {string} label - Human readable name of the audio input. Empty until a media permission was granted. + * @property {string} [groupId] - Identifier of the group the device belongs to. + */ + +/** + * Reports the audio inputs available on this controller and the one the current stream uses. + * Can only be called by a controller (not the screen). + * + * The platform needs this to offer the player a different microphone when opening one takes the audio focus away, for + * example when a phone controller connected to a car captures through the car's Bluetooth microphone and the platform + * would otherwise pause. When the player picks a different audio input, + * {@link AirConsole.prototype.onAudioInputDeviceChange} is called with its `deviceId`. + * + * Call this whenever the situation changes: after a stream was opened, after switching to another device, and when + * `navigator.mediaDevices` fires `devicechange`. Report an empty list once the microphone is no longer in use, so the + * platform does not offer devices for a stream that does not exist anymore. + * + * Note: `deviceId` values are scoped to the origin that enumerated them. The platform only displays the `label` and + * hands the `deviceId` back unchanged. + * + * @param {Array} devices - Devices to report. Entries of a kind other + * than `audioinput` and entries without a `deviceId` are ignored, so the result of `enumerateDevices()` can be + * passed as is. + * @param {string} [activeDeviceId] - The `deviceId` the current stream uses, e.g. + * `stream.getAudioTracks()[0].getSettings().deviceId`. + * + * @example + * const devices = await navigator.mediaDevices.enumerateDevices(); + * airconsole.setAudioInputDevices(devices, stream.getAudioTracks()[0].getSettings().deviceId); + * + * @see AirConsole.prototype.onAudioInputDeviceChange + * @see AirConsole.prototype.getUserMedia + */ +AirConsole.prototype.setAudioInputDevices = function setAudioInputDevices(devices, activeDeviceId) { + if (this.device_id === AirConsole.SCREEN) { + throw "Only controllers can call setAudioInputDevices!"; + } + + var audioInputs = (devices || []).filter(function isReportableAudioInput(device) { + return !!device && !!device.deviceId && (!device.kind || device.kind === 'audioinput'); + }).map(function toAudioInputDevice(device) { + return { + deviceId: device.deviceId, + label: device.label || '', + groupId: device.groupId || '' + }; + }); + + this.audioInputDevices_ = audioInputs; + this.activeAudioInputDeviceId_ = activeDeviceId || ''; + + this.sendEvent_('audioInputDevicesReported', { + devices: audioInputs, + activeDeviceId: this.activeAudioInputDeviceId_ + }); +}; + +/** + * Gets called when the platform asks the game to capture from a different audio input, because the player picked one. + * Close the current stream and open a new one on the given device, then report the new situation with + * {@link AirConsole.prototype.setAudioInputDevices}. + * @abstract + * @param {string} device_id - The `deviceId` of the audio input to use, as previously reported by the game. + * + * @example + * airconsole.onAudioInputDeviceChange = async function (device_id) { + * stream.getTracks().forEach(function (t) { t.stop(); }); + * stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: device_id } } }); + * const devices = await navigator.mediaDevices.enumerateDevices(); + * airconsole.setAudioInputDevices(devices, device_id); + * }; + * + * @see AirConsole.prototype.setAudioInputDevices + */ +AirConsole.prototype.onAudioInputDeviceChange = function(device_id) {}; + /** * Releases resources held by this AirConsole instance. * Call this when the instance is no longer needed — for example, when navigating @@ -1693,6 +1779,13 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === 'event') { const { type } = data; + // Audio input selection happens while a stream is already open, so it is handled before the media permission + // guard below, which only applies to the events of a pending permission request. + if (type === 'setAudioInputDevice') { + me.onAudioInputDeviceChange(data.data ? data.data.deviceId : undefined); + return; + } + // Guard: ignore stale platform messages that arrive after state has been cleaned up // (e.g. after the 30-second timeout has already resolved the pending Promise). if (!me.mediaPermissionPending_) { diff --git a/beta/airconsole-1.11.1.js b/beta/airconsole-1.11.1.js index 9004bd2..5ef6360 100644 --- a/beta/airconsole-1.11.1.js +++ b/beta/airconsole-1.11.1.js @@ -875,6 +875,12 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { // Send the request to the platform to decide where and how the user media request needs to take place based on // browser or controller environment. me.sendEvent_('requestUserMediaPermission', { constraints: constraints }); + // Let the platform know a microphone is being requested. The platform uses this to recognize situations where + // opening the microphone is what takes the audio focus away from the platform (E.g. a phone controller capturing + // through a car's Bluetooth microphone), so it can offer the player another audio input instead of pausing. + if (constraints.audio) { + me.sendEvent_('microphoneRequested', {}); + } }); }; @@ -899,6 +905,86 @@ AirConsole.prototype.rejectMediaPermission_ = function rejectMediaPermission_(er if (cb) { cb.reject(error); } } +/** + * @typedef {Object} AirConsole~AudioInputDevice + * @property {string} deviceId - Identifier of the audio input, as reported by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices enumerateDevices}. + * @property {string} label - Human readable name of the audio input. Empty until a media permission was granted. + * @property {string} [groupId] - Identifier of the group the device belongs to. + */ + +/** + * Reports the audio inputs available on this controller and the one the current stream uses. + * Can only be called by a controller (not the screen). + * + * The platform needs this to offer the player a different microphone when opening one takes the audio focus away, for + * example when a phone controller connected to a car captures through the car's Bluetooth microphone and the platform + * would otherwise pause. When the player picks a different audio input, + * {@link AirConsole.prototype.onAudioInputDeviceChange} is called with its `deviceId`. + * + * Call this whenever the situation changes: after a stream was opened, after switching to another device, and when + * `navigator.mediaDevices` fires `devicechange`. Report an empty list once the microphone is no longer in use, so the + * platform does not offer devices for a stream that does not exist anymore. + * + * Note: `deviceId` values are scoped to the origin that enumerated them. The platform only displays the `label` and + * hands the `deviceId` back unchanged. + * + * @param {Array} devices - Devices to report. Entries of a kind other + * than `audioinput` and entries without a `deviceId` are ignored, so the result of `enumerateDevices()` can be + * passed as is. + * @param {string} [activeDeviceId] - The `deviceId` the current stream uses, e.g. + * `stream.getAudioTracks()[0].getSettings().deviceId`. + * + * @example + * const devices = await navigator.mediaDevices.enumerateDevices(); + * airconsole.setAudioInputDevices(devices, stream.getAudioTracks()[0].getSettings().deviceId); + * + * @see AirConsole.prototype.onAudioInputDeviceChange + * @see AirConsole.prototype.getUserMedia + */ +AirConsole.prototype.setAudioInputDevices = function setAudioInputDevices(devices, activeDeviceId) { + if (this.device_id === AirConsole.SCREEN) { + throw "Only controllers can call setAudioInputDevices!"; + } + + var audioInputs = (devices || []).filter(function isReportableAudioInput(device) { + return !!device && !!device.deviceId && (!device.kind || device.kind === 'audioinput'); + }).map(function toAudioInputDevice(device) { + return { + deviceId: device.deviceId, + label: device.label || '', + groupId: device.groupId || '' + }; + }); + + this.audioInputDevices_ = audioInputs; + this.activeAudioInputDeviceId_ = activeDeviceId || ''; + + this.sendEvent_('audioInputDevicesReported', { + devices: audioInputs, + activeDeviceId: this.activeAudioInputDeviceId_ + }); +}; + +/** + * Gets called when the platform asks the game to capture from a different audio input, because the player picked one. + * Close the current stream and open a new one on the given device, then report the new situation with + * {@link AirConsole.prototype.setAudioInputDevices}. + * @abstract + * @param {string} device_id - The `deviceId` of the audio input to use, as previously reported by the game. + * + * @example + * airconsole.onAudioInputDeviceChange = async function (device_id) { + * stream.getTracks().forEach(function (t) { t.stop(); }); + * stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: device_id } } }); + * const devices = await navigator.mediaDevices.enumerateDevices(); + * airconsole.setAudioInputDevices(devices, device_id); + * }; + * + * @see AirConsole.prototype.setAudioInputDevices + */ +AirConsole.prototype.onAudioInputDeviceChange = function(device_id) {}; + /** * Releases resources held by this AirConsole instance. * Call this when the instance is no longer needed — for example, when navigating @@ -1693,6 +1779,13 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === 'event') { const { type } = data; + // Audio input selection happens while a stream is already open, so it is handled before the media permission + // guard below, which only applies to the events of a pending permission request. + if (type === 'setAudioInputDevice') { + me.onAudioInputDeviceChange(data.data ? data.data.deviceId : undefined); + return; + } + // Guard: ignore stale platform messages that arrive after state has been cleaned up // (e.g. after the 30-second timeout has already resolved the pending Promise). if (!me.mediaPermissionPending_) { diff --git a/tests/airconsole-1.11.0-spec.html b/tests/airconsole-1.11.0-spec.html index a6ebca6..b5ff31f 100644 --- a/tests/airconsole-1.11.0-spec.html +++ b/tests/airconsole-1.11.0-spec.html @@ -31,6 +31,7 @@ + diff --git a/tests/airconsole-1.11.1-spec.html b/tests/airconsole-1.11.1-spec.html index ddd2d89..db534e2 100644 --- a/tests/airconsole-1.11.1-spec.html +++ b/tests/airconsole-1.11.1-spec.html @@ -31,6 +31,7 @@ + diff --git a/tests/spec/airconsole-1.11.0-spec.js b/tests/spec/airconsole-1.11.0-spec.js index 5c2477f..79e5e0d 100644 --- a/tests/spec/airconsole-1.11.0-spec.js +++ b/tests/spec/airconsole-1.11.0-spec.js @@ -398,6 +398,19 @@ describe("AirConsole 1.11.0", function () { testUserMediaPermissions(); }); + /** + ====================================================================================== + TEST AUDIO INPUT DEVICE SELECTION FUNCTIONALITY + */ + + describe("Audio Input Devices", function () { + afterEach(function () { + tearDown(); + }); + + testAudioInputDevices(); + }); + /** ====================================================================================== TEST CONFIGURATION FUNCTIONALITY diff --git a/tests/spec/methods/spec-audio-input-devices.js b/tests/spec/methods/spec-audio-input-devices.js new file mode 100644 index 0000000..28548a3 --- /dev/null +++ b/tests/spec/methods/spec-audio-input-devices.js @@ -0,0 +1,224 @@ +function testAudioInputDevices() { + // --- Shared helpers --- + + function initAirConsoleAsController() { + spyOn(document, 'getElementsByTagName').and.callFake(function () { + return [{ src: 'http://localhost/api/airconsole-latest.js' }]; + }); + airconsole = new AirConsole({ setup_document: false }); + airconsole.device_id = DEVICE_ID; // 2 = controller + airconsole.devices[0] = {}; + airconsole.devices[DEVICE_ID] = { uid: 1237, nicktype: 'Sergio', location: LOCATION, custom: {} }; + } + + function teardown() { + if (airconsole) { + window.removeEventListener('message', airconsole.messageEventListener_); + airconsole = null; + } + } + + function makeFakeStream() { + return { + getAudioTracks: function () { + return [{}]; + }, + getTracks: function () { + return [{ + stop: () => { + } + }]; + }, + }; + } + + function makeDeviceInfo(overwrites) { + return Object.assign({ + deviceId: 'phone-mic', + kind: 'audioinput', + label: 'Phone microphone', + groupId: 'group-1' + }, overwrites || {}); + } + + function lastEventOfType(type) { + var calls = airconsole.sendEvent_.calls.all(); + for (var i = calls.length - 1; i >= 0; i -= 1) { + if (calls[i].args[0] === type) { + return calls[i].args[1]; + } + } + return undefined; + } + + function setAudioInputDevice(deviceId) { + dispatchCustomMessageEvent({ + action: 'event', + type: 'setAudioInputDevice', + data: { deviceId: deviceId }, + }); + } + + describe('microphoneRequested', function () { + beforeEach(function () { + initAirConsoleAsController(); + spyOn(airconsole, 'sendEvent_'); + }); + + afterEach(function () { + teardown(); + }); + + it('Should inform the platform that a microphone was requested', function () { + airconsole.getUserMedia({ audio: true }); + + expect(airconsole.sendEvent_).toHaveBeenCalledWith('microphoneRequested', {}); + }); + + it('Should not inform the platform when the request is rejected before reaching the platform', function (done) { + airconsole.device_id = AirConsole.SCREEN; + + airconsole.getUserMedia({ audio: true }).catch(function () { + expect(lastEventOfType('microphoneRequested')).toBeUndefined(); + done(); + }); + }); + }); + + describe('setAudioInputDevices', function () { + beforeEach(function () { + initAirConsoleAsController(); + spyOn(airconsole, 'sendEvent_'); + }); + + afterEach(function () { + teardown(); + }); + + it('Should report the devices and the active device to the platform', function () { + airconsole.setAudioInputDevices([makeDeviceInfo()], 'phone-mic'); + + expect(airconsole.sendEvent_).toHaveBeenCalledWith('audioInputDevicesReported', { + devices: [{ deviceId: 'phone-mic', label: 'Phone microphone', groupId: 'group-1' }], + activeDeviceId: 'phone-mic' + }); + }); + + it('Should ignore devices that are not audio inputs', function () { + airconsole.setAudioInputDevices([ + makeDeviceInfo(), + makeDeviceInfo({ deviceId: 'cam', kind: 'videoinput', label: 'Camera' }), + makeDeviceInfo({ deviceId: 'speaker', kind: 'audiooutput', label: 'Speaker' }) + ], 'phone-mic'); + + expect(lastEventOfType('audioInputDevicesReported').devices).toEqual([ + { deviceId: 'phone-mic', label: 'Phone microphone', groupId: 'group-1' } + ]); + }); + + it('Should ignore devices without a deviceId', function () { + airconsole.setAudioInputDevices([makeDeviceInfo({ deviceId: '' }), makeDeviceInfo()], 'phone-mic'); + + expect(lastEventOfType('audioInputDevicesReported').devices).toEqual([ + { deviceId: 'phone-mic', label: 'Phone microphone', groupId: 'group-1' } + ]); + }); + + it('Should report plain objects without a kind', function () { + airconsole.setAudioInputDevices([{ deviceId: 'car-mic', label: 'Car microphone' }], 'car-mic'); + + expect(lastEventOfType('audioInputDevicesReported').devices).toEqual([ + { deviceId: 'car-mic', label: 'Car microphone', groupId: '' } + ]); + }); + + it('Should report an empty label for devices the browser did not name', function () { + airconsole.setAudioInputDevices([makeDeviceInfo({ label: undefined })], 'phone-mic'); + + expect(lastEventOfType('audioInputDevicesReported').devices[0].label).toBe(''); + }); + + it('Should report an empty list when the microphone is no longer in use', function () { + airconsole.setAudioInputDevices([]); + + expect(airconsole.sendEvent_).toHaveBeenCalledWith('audioInputDevicesReported', { + devices: [], + activeDeviceId: '' + }); + }); + + it('Should report an empty active device when none was given', function () { + airconsole.setAudioInputDevices([makeDeviceInfo()]); + + expect(lastEventOfType('audioInputDevicesReported').activeDeviceId).toBe(''); + }); + + it('Should throw when called on the screen', function () { + airconsole.device_id = AirConsole.SCREEN; + + expect(function () { + airconsole.setAudioInputDevices([makeDeviceInfo()], 'phone-mic'); + }).toThrow(); + }); + }); + + describe('onAudioInputDeviceChange', function () { + beforeEach(function () { + initAirConsoleAsController(); + }); + + afterEach(function () { + teardown(); + }); + + it('Should be called when no media permission request is pending', function () { + // The platform asks for a different audio input long after the permission flow settled, which is exactly the + // state in which inbound permission events are ignored. + expect(airconsole.mediaPermissionPending_).toBeFalsy(); + spyOn(airconsole, 'onAudioInputDeviceChange'); + + setAudioInputDevice('car-mic'); + + expect(airconsole.onAudioInputDeviceChange).toHaveBeenCalledWith('car-mic'); + }); + + it('Should be called while a media permission request is pending', function () { + spyOn(airconsole, 'sendEvent_'); + airconsole.getUserMedia({ audio: true }); + spyOn(airconsole, 'onAudioInputDeviceChange'); + + setAudioInputDevice('car-mic'); + + expect(airconsole.onAudioInputDeviceChange).toHaveBeenCalledWith('car-mic'); + }); + + it('Should not settle a pending getUserMedia promise', function (done) { + var settled = false; + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(makeFakeStream())); + spyOn(airconsole, 'sendEvent_'); + + airconsole.getUserMedia({ audio: true }).then( + function () { settled = true; }, + function () { settled = true; } + ); + + setAudioInputDevice('car-mic'); + + setTimeout(function () { + expect(settled).toBe(false); + expect(airconsole.mediaPermissionPending_).toBe(true); + done(); + }, 50); + }); + + it('Should still ignore permission events when no request is pending', function () { + spyOn(airconsole, 'onAudioInputDeviceChange'); + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(makeFakeStream())); + + dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); + + expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled(); + expect(airconsole.onAudioInputDeviceChange).not.toHaveBeenCalled(); + }); + }); +} From 9f5d8d80d955a8218ed931eeeed194b3ac55ae51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 12:28:20 +0000 Subject: [PATCH 2/2] chore: add DRG logs to the audio input device flow Temporary instrumentation to track the data flow while investigating why a controller in a car still pauses after it obtained a microphone stream. Logs the microphoneRequested send, the stream-open event the platform uses as its second anchor, the reported devices and the inbound device selection. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- airconsole-1.11.0.js | 7 +++++++ beta/airconsole-1.11.1.js | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index a3ced4f..222a69b 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -879,6 +879,7 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { // opening the microphone is what takes the audio focus away from the platform (E.g. a phone controller capturing // through a car's Bluetooth microphone), so it can offer the player another audio input instead of pausing. if (constraints.audio) { + console.log('DRG:airconsole-1.11.0.js: 882:getUserMedia:microphoneRequested:', constraints); me.sendEvent_('microphoneRequested', {}); } }); @@ -960,6 +961,9 @@ AirConsole.prototype.setAudioInputDevices = function setAudioInputDevices(device this.audioInputDevices_ = audioInputs; this.activeAudioInputDeviceId_ = activeDeviceId || ''; + console.log('DRG:airconsole-1.11.0.js: 964:setAudioInputDevices:audioInputs:', audioInputs, + this.activeAudioInputDeviceId_); + this.sendEvent_('audioInputDevicesReported', { devices: audioInputs, activeDeviceId: this.activeAudioInputDeviceId_ @@ -1782,6 +1786,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { // Audio input selection happens while a stream is already open, so it is handled before the media permission // guard below, which only applies to the events of a pending permission request. if (type === 'setAudioInputDevice') { + console.log('DRG:airconsole-1.11.0.js: 1789:onPostMessage_:setAudioInputDevice:', data.data); me.onAudioInputDeviceChange(data.data ? data.data.deviceId : undefined); return; } @@ -1815,6 +1820,8 @@ AirConsole.prototype.onPostMessage_ = function(event) { // Note: 'userMediaPermissionGranted' is both sent upward (controller → platform) and // received downward (platform → controller for native controllers). The direction is // determined by context: outbound is sent here; inbound is handled by this event branch. + console.log('DRG:airconsole-1.11.0.js: 1823:onPostMessage_:userMediaPermissionGranted:', + me.mediaPermissionConstraints_); me.sendEvent_('userMediaPermissionGranted', { constraints: me.mediaPermissionConstraints_, }); diff --git a/beta/airconsole-1.11.1.js b/beta/airconsole-1.11.1.js index 5ef6360..aa23571 100644 --- a/beta/airconsole-1.11.1.js +++ b/beta/airconsole-1.11.1.js @@ -879,6 +879,7 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { // opening the microphone is what takes the audio focus away from the platform (E.g. a phone controller capturing // through a car's Bluetooth microphone), so it can offer the player another audio input instead of pausing. if (constraints.audio) { + console.log('DRG:airconsole-1.11.1.js: 882:getUserMedia:microphoneRequested:', constraints); me.sendEvent_('microphoneRequested', {}); } }); @@ -960,6 +961,9 @@ AirConsole.prototype.setAudioInputDevices = function setAudioInputDevices(device this.audioInputDevices_ = audioInputs; this.activeAudioInputDeviceId_ = activeDeviceId || ''; + console.log('DRG:airconsole-1.11.1.js: 964:setAudioInputDevices:audioInputs:', audioInputs, + this.activeAudioInputDeviceId_); + this.sendEvent_('audioInputDevicesReported', { devices: audioInputs, activeDeviceId: this.activeAudioInputDeviceId_ @@ -1782,6 +1786,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { // Audio input selection happens while a stream is already open, so it is handled before the media permission // guard below, which only applies to the events of a pending permission request. if (type === 'setAudioInputDevice') { + console.log('DRG:airconsole-1.11.1.js: 1789:onPostMessage_:setAudioInputDevice:', data.data); me.onAudioInputDeviceChange(data.data ? data.data.deviceId : undefined); return; } @@ -1815,6 +1820,8 @@ AirConsole.prototype.onPostMessage_ = function(event) { // Note: 'userMediaPermissionGranted' is both sent upward (controller → platform) and // received downward (platform → controller for native controllers). The direction is // determined by context: outbound is sent here; inbound is handled by this event branch. + console.log('DRG:airconsole-1.11.1.js: 1823:onPostMessage_:userMediaPermissionGranted:', + me.mediaPermissionConstraints_); me.sendEvent_('userMediaPermissionGranted', { constraints: me.mediaPermissionConstraints_, });