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..222a69b 100644
--- a/airconsole-1.11.0.js
+++ b/airconsole-1.11.0.js
@@ -875,6 +875,13 @@ 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) {
+ console.log('DRG:airconsole-1.11.0.js: 882:getUserMedia:microphoneRequested:', constraints);
+ me.sendEvent_('microphoneRequested', {});
+ }
});
};
@@ -899,6 +906,89 @@ 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 || '';
+
+ console.log('DRG:airconsole-1.11.0.js: 964:setAudioInputDevices:audioInputs:', audioInputs,
+ this.activeAudioInputDeviceId_);
+
+ 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 +1783,14 @@ 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') {
+ console.log('DRG:airconsole-1.11.0.js: 1789:onPostMessage_:setAudioInputDevice:', data.data);
+ 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_) {
@@ -1722,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 9004bd2..aa23571 100644
--- a/beta/airconsole-1.11.1.js
+++ b/beta/airconsole-1.11.1.js
@@ -875,6 +875,13 @@ 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) {
+ console.log('DRG:airconsole-1.11.1.js: 882:getUserMedia:microphoneRequested:', constraints);
+ me.sendEvent_('microphoneRequested', {});
+ }
});
};
@@ -899,6 +906,89 @@ 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 || '';
+
+ console.log('DRG:airconsole-1.11.1.js: 964:setAudioInputDevices:audioInputs:', audioInputs,
+ this.activeAudioInputDeviceId_);
+
+ 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 +1783,14 @@ 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') {
+ console.log('DRG:airconsole-1.11.1.js: 1789:onPostMessage_:setAudioInputDevice:', data.data);
+ 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_) {
@@ -1722,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_,
});
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();
+ });
+ });
+}