From e28bb4a6beeff1873edf095b66445bb9e11b55dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:50:18 +0000 Subject: [PATCH 01/11] ! F: Ask the platform for the preferred audio input device in getUserMedia getUserMedia now runs a request/response exchange before handing a stream to the game, so car head units can keep games on the phone's built-in microphone: - First request of a game: open stream #1, enumerate, send requestPreferredAudioInputDevice({devices, activeDeviceId}), then keep stream #1, reopen it if it ended, or stop it and open stream #2 with the non-exact deviceId. - Later requests (after an accepted reply): no open on the hand-off, a list-less request, then a single open. - setAudioInputDevices after every successful resolve; userMediaRequestFailed({reason, error}) where the base sends nothing. - The hand-off and the reply are each accepted once per request. - One permission timeout, 60 s of unpaused time, stopped while the screen is paused; a missing screen `paused` reads as not paused. On expiry it stops held streams and notifies the platform before rejecting. #skip-tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- CHANGELOG.md | 11 + airconsole-1.11.0.js | 387 ++++++++++++++++-- .../methods/spec-usermedia-permissions.js | 386 ++++++++++++++++- 3 files changed, 737 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2595b..d370682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ ### Added +- `AirConsole.getUserMedia` asks the platform which audio input a stream should use before handing it to the game + (`requestPreferredAudioInputDevice` / `preferredAudioInputDevice`), so car head units can keep games on the phone's + built-in microphone. The first request of a game opens once, then keeps that stream, reopens it, or switches to the + preferred device; later requests open once with the device merged in as a non-exact `deviceId`. +- The API reports the audio inputs and the active device after every successful open (`setAudioInputDevices`), and + failures the platform would not otherwise learn of (`userMediaRequestFailed`). + +### Changed + +- The `getUserMedia` timeout is now 60 s of unpaused time: it stops while the screen is paused. + ## [1.11.0] - 2026-07-07 ### Added diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index eb43da0..59e3689 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -806,6 +806,15 @@ AirConsole.prototype.onUserMediaAccessDenied = function (device_id) {}; */ const mediaPermissionCallbacks_ = new WeakMap(); +/** + * How long a getUserMedia request may stay unsettled, counted in unpaused time: the clock stops while the screen is + * paused. It covers everything a request waits on: the car-connection notice, the permission flow, the preferred + * audio input device exchange (including the built-in microphone requirement and the picker), the open and the web + * denial overlay. + * @private + */ +const PERMISSION_TIMEOUT_MS = 60000; + /** * @typedef {Object} AirConsole~GetUserMediaConstraint * @property {boolean} audio - Whether to request microphone permissions. Must be provided; video is not supported. @@ -827,6 +836,10 @@ const mediaPermissionCallbacks_ = new WeakMap(); * Note: callers are responsible for stopping stream tracks when the stream is no longer needed: * `stream.getTracks().forEach(function(t) { t.stop(); })` * + * The platform may prefer another audio input (e.g. the phone's built-in microphone on a car head unit); the + * API then resolves with a stream on that device. A request that stays unsettled for 60 s of unpaused time + * rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock stops while the screen is paused. + * * @example * airconsole.getUserMedia({ audio: true }).then(function(stream) { * console.info('Media access granted', stream); @@ -867,10 +880,13 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { return new Promise(function (resolve, reject) { me.mediaPermissionConstraints_ = constraints; me.mediaPermissionPending_ = true; + me.mediaPermissionRequestId_ = (me.mediaPermissionRequestId_ || 0) + 1; + me.mediaPermissionHandOffAccepted_ = false; + me.preferredAudioInputDeviceHandler_ = null; + me.mediaPermissionStreams_ = []; mediaPermissionCallbacks_.set(me, { resolve: resolve, reject: reject }); - me.mediaPermissionTimeout_ = setTimeout(function() { - me.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); - }, 45000); + me.mediaPermissionRemainingMs_ = PERMISSION_TIMEOUT_MS; + me.updatePermissionClock_(); // 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. @@ -883,6 +899,10 @@ AirConsole.prototype.cleanUpMediaPermission_ = function cleanUpMediaPermission_( this.mediaPermissionPending_ = false; this.mediaPermissionConstraints_ = undefined; this.mediaPermissionTimeout_ = undefined; + this.mediaPermissionTimerStartedAt_ = undefined; + this.mediaPermissionHandOffAccepted_ = false; + this.preferredAudioInputDeviceHandler_ = null; + this.mediaPermissionStreams_ = []; this.cachedMediaError_ = null; mediaPermissionCallbacks_.delete(this); } @@ -899,6 +919,318 @@ AirConsole.prototype.rejectMediaPermission_ = function rejectMediaPermission_(er if (cb) { cb.reject(error); } } +/** + * Returns true while the screen is paused, as published in its presence (`paused = { state, reason }`). + * The screen only publishes `paused` once its pause state first changes, so a missing `paused`, or a missing screen + * entry, means "not paused". + * @private + * @return {boolean} + */ +AirConsole.prototype.isScreenPaused_ = function isScreenPaused_() { + const screen = this.devices && this.devices[AirConsole.SCREEN]; + return !!(screen && screen.paused && screen.paused.state); +}; + +/** + * Starts or stops the permission clock to match the screen's pause state, keeping the remaining time across a pause. + * Called when a request starts and whenever the screen's presence changes. + * @private + */ +AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() { + var me = this; + if (!me.mediaPermissionPending_) { + return; + } + const running = me.mediaPermissionTimeout_ !== undefined; + if (me.isScreenPaused_()) { + if (running) { + clearTimeout(me.mediaPermissionTimeout_); + me.mediaPermissionTimeout_ = undefined; + me.mediaPermissionRemainingMs_ -= Date.now() - me.mediaPermissionTimerStartedAt_; + me.mediaPermissionTimerStartedAt_ = undefined; + } + } else if (!running) { + me.mediaPermissionTimerStartedAt_ = Date.now(); + me.mediaPermissionTimeout_ = setTimeout(function () { + me.expireMediaPermission_(); + }, Math.max(0, me.mediaPermissionRemainingMs_)); + } +}; + +/** + * Ends the pending request once the permission timeout expires: stops any stream the request holds, tells the + * platform, then rejects the game's promise. Sending before rejecting means the platform has handled the timeout + * before the game can retry. + * @private + */ +AirConsole.prototype.expireMediaPermission_ = function expireMediaPermission_() { + this.mediaPermissionTimeout_ = undefined; + this.stopMediaPermissionStreams_(); + this.sendEvent_('userMediaRequestFailed', { + reason: 'timeout', + error: AirConsole.USER_MEDIA_ERROR_TYPE.timeout, + }); + this.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); +}; + +/** + * Returns true if requestId is the request that is still pending. + * @private + * @param {number} requestId + * @return {boolean} + */ +AirConsole.prototype.isCurrentMediaPermission_ = function isCurrentMediaPermission_(requestId) { + return this.mediaPermissionPending_ && this.mediaPermissionRequestId_ === requestId; +}; + +/** + * Stops the tracks of every stream the pending request has opened and not handed to the game. + * @private + */ +AirConsole.prototype.stopMediaPermissionStreams_ = function stopMediaPermissionStreams_() { + (this.mediaPermissionStreams_ || []).forEach(function (stream) { + stream.getTracks().forEach(function (t) { t.stop(); }); + }); + this.mediaPermissionStreams_ = []; +}; + +/** + * Returns the name the platform receives for an error: the message of an AirConsoleUserMediaError, or the name of a + * browser DOMException. + * @private + * @param {*} error + * @return {string} + */ +AirConsole.getUserMediaErrorName_ = function getUserMediaErrorName_(error) { + if (error instanceof AirConsoleUserMediaError) { + return error.message; + } + return (error && error.name) || String(error); +}; + +/** + * Returns the device id the stream's audio track captures from, or '' when it has none. + * @private + * @param {MediaStream} stream + * @return {string} + */ +AirConsole.getActiveAudioInputDeviceId_ = function getActiveAudioInputDeviceId_(stream) { + const track = stream && stream.getAudioTracks && stream.getAudioTracks()[0]; + if (!track || track.readyState === 'ended' || !track.getSettings) { + return ''; + } + return track.getSettings().deviceId || ''; +}; + +/** + * Returns the audio inputs from enumerateDevices(), or an empty list if they cannot be enumerated. + * @private + * @return {Promise>} + */ +AirConsole.enumerateAudioInputDevices_ = function enumerateAudioInputDevices_() { + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) { + return Promise.resolve([]); + } + return navigator.mediaDevices.enumerateDevices().then(function (devices) { + return devices.filter(function (device) { + return device.kind === 'audioinput'; + }).map(function (device) { + return { deviceId: device.deviceId, label: device.label }; + }); + }, function () { + return []; + }); +}; + +/** + * Returns the game's constraints with deviceId merged into `audio` as a plain (non-exact) value, so the browser + * treats it as a preference and opens another microphone when that device is gone. + * @private + * @param {AirConsole~GetUserMediaConstraint} constraints + * @param {string|null} deviceId + * @return {Object} + */ +AirConsole.getConstraintsWithAudioInputDevice_ = function getConstraintsWithAudioInputDevice_(constraints, deviceId) { + if (!deviceId) { + return constraints; + } + const audio = typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {}; + audio.deviceId = deviceId; + return Object.assign({}, constraints, { audio: audio }); +}; + +/** + * Asks the platform which audio input the stream should use and waits for its preferredAudioInputDevice reply. + * The reply is accepted once per request, and only while it is awaited. + * @private + * @param {number} requestId + * @param {Object} payload - { devices, activeDeviceId } on a first request; empty on a later one. + * @param {function(string|null)} onReply + */ +AirConsole.prototype.requestPreferredAudioInputDevice_ = function requestPreferredAudioInputDevice_(requestId, + payload, onReply) { + var me = this; + if (!me.isCurrentMediaPermission_(requestId)) { + return; + } + me.preferredAudioInputDeviceHandler_ = function (deviceId) { + me.preferredAudioInputDeviceHandler_ = null; + me.audioInputExchangeCompleted_ = true; + onReply(deviceId || null); + }; + me.sendEvent_('requestPreferredAudioInputDevice', payload); +}; + +/** + * Hands a stream to the game and reports the devices and the device the stream actually captures from. + * @private + * @param {number} requestId + * @param {MediaStream} stream + */ +AirConsole.prototype.resolveMediaPermissionWithReport_ = function resolveMediaPermissionWithReport_(requestId, + stream) { + var me = this; + if (!me.isCurrentMediaPermission_(requestId)) { + stream.getTracks().forEach(function (t) { t.stop(); }); + return; + } + me.mediaPermissionStreams_ = me.mediaPermissionStreams_.filter(function (s) { return s !== stream; }); + // 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 the event branch. + me.sendEvent_('userMediaPermissionGranted', { + constraints: me.mediaPermissionConstraints_, + }); + me.resolveMediaPermission_(stream); + AirConsole.enumerateAudioInputDevices_().then(function (devices) { + me.sendEvent_('setAudioInputDevices', { + devices: devices, + activeDeviceId: AirConsole.getActiveAudioInputDeviceId_(stream), + }); + }); +}; + +/** + * Ends the pending request after an open failed for a reason other than a permission denial: tells the platform, + * then lets the error reach the game. + * @private + * @param {number} requestId + * @param {*} error + */ +AirConsole.prototype.failMediaPermissionOpen_ = function failMediaPermissionOpen_(requestId, error) { + if (!this.isCurrentMediaPermission_(requestId)) { + return; + } + this.sendEvent_('userMediaRequestFailed', { + reason: 'open-error', + error: AirConsole.getUserMediaErrorName_(error), + }); + this.rejectMediaPermission_(error); +}; + +/** + * Opens a stream for the pending request, holding it until it is handed to the game. + * @private + * @param {number} requestId + * @param {Object} constraints + * @return {Promise} null when the request ended while the browser was opening. + */ +AirConsole.prototype.openMediaPermissionStream_ = function openMediaPermissionStream_(requestId, constraints) { + var me = this; + return navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { + if (!me.isCurrentMediaPermission_(requestId)) { + // The request ended while the browser was opening; stop the orphaned stream to release hardware. + stream.getTracks().forEach(function (t) { t.stop(); }); + return null; + } + me.mediaPermissionStreams_.push(stream); + return stream; + }); +}; + +/** + * First request of a game: opens stream #1 with the game's constraints, asks for the preferred audio input device + * with the device list, then keeps stream #1, reopens a fresh stream if it ended, or switches to stream #2. + * @private + * @param {number} requestId + * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). + */ +AirConsole.prototype.startFirstMediaPermissionRequest_ = function startFirstMediaPermissionRequest_(requestId, + handOffType) { + var me = this; + const constraints = me.mediaPermissionConstraints_; + me.openMediaPermissionStream_(requestId, constraints).then(function (stream1) { + if (!stream1) { + return; + } + AirConsole.enumerateAudioInputDevices_().then(function (devices) { + me.requestPreferredAudioInputDevice_(requestId, { + devices: devices, + activeDeviceId: AirConsole.getActiveAudioInputDeviceId_(stream1), + }, function (deviceId) { + const track = stream1.getAudioTracks()[0]; + const live = !!track && track.readyState === 'live'; + if (live && (deviceId === null || deviceId === AirConsole.getActiveAudioInputDeviceId_(stream1))) { + me.resolveMediaPermissionWithReport_(requestId, stream1); + return; + } + // Stream #1 ended, or is on another device: iOS refuses a second audio input while the first is live, + // so stop it before opening the next one. + me.stopMediaPermissionStreams_(); + me.openMediaPermissionStream_(requestId, + AirConsole.getConstraintsWithAudioInputDevice_(constraints, deviceId)).then(function (stream) { + if (stream) { + me.resolveMediaPermissionWithReport_(requestId, stream); + } + }, function (error) { + me.failMediaPermissionOpen_(requestId, error); + }); + }); + }); + }, function (error) { + if (!me.isCurrentMediaPermission_(requestId)) { + return; + } + if (handOffType === 'userMediaPermissionGranted') { + // Native controller: platform already granted permission but stream open failed. + me.failMediaPermissionOpen_(requestId, error); + } else { + me.cachedMediaError_ = error; + me.sendEvent_('userMediaPermissionDenied'); + } + }); +}; + +/** + * Later request in the same game: asks for the preferred audio input device before opening anything, then opens + * once with it merged in as a non-exact constraint. + * @private + * @param {number} requestId + */ +AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMediaPermissionRequest_(requestId) { + var me = this; + const constraints = me.mediaPermissionConstraints_; + me.requestPreferredAudioInputDevice_(requestId, {}, function (deviceId) { + me.openMediaPermissionStream_(requestId, + AirConsole.getConstraintsWithAudioInputDevice_(constraints, deviceId)).then(function (stream) { + if (stream) { + me.resolveMediaPermissionWithReport_(requestId, stream); + } + }, function (error) { + if (!me.isCurrentMediaPermission_(requestId)) { + return; + } + if (error && error.name === 'NotAllowedError') { + // The permission was revoked: a permission denial, which takes the base's denial path. + me.cachedMediaError_ = error; + me.sendEvent_('userMediaPermissionDenied'); + } else { + me.failMediaPermissionOpen_(requestId, error); + } + }); + }); +}; + /** * Releases resources held by this AirConsole instance. * Call this when the instance is no longer needed — for example, when navigating @@ -1557,6 +1889,9 @@ AirConsole.prototype.onPostMessage_ = function(event) { var sender = data.device_id; me.devices[sender] = data.device_data; + if (sender === AirConsole.SCREEN) { + me.updatePermissionClock_(); + } me.onDeviceStateChange(sender, data.device_data); var is_connect = me.isLocationLoadedMessage_(game_url_before, game_url, game_url_after); var is_disconnect = me.isLocationUnloadedMessage_(game_url_before, game_url, game_url_after); @@ -1596,6 +1931,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === "ready") { me.device_id = data.device_id; me.devices = data.devices; + me.updatePermissionClock_(); if (me.server_time_offset !== false) { me.server_time_offset = data.server_time_offset || 0; } @@ -1711,35 +2047,22 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.rejectMediaPermission_(error); } } else if (type === 'userMediaPermissionGranted' || type === 'promptUserMediaPermission') { - navigator.mediaDevices.getUserMedia(me.mediaPermissionConstraints_).then( - function success(stream) { - if (!me.mediaPermissionPending_) { - // Timeout fired while the browser permission dialog was still open; - // the outer Promise is already settled — stop the orphaned stream to release hardware. - stream.getTracks().forEach(function (t) { t.stop(); }); - return; - } - // 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. - me.sendEvent_('userMediaPermissionGranted', { - constraints: me.mediaPermissionConstraints_, - }); - me.resolveMediaPermission_(stream); - }, - function failure(error) { - if (!me.mediaPermissionPending_) { - return; - } - // Native controller: platform already granted permission but stream open failed. - if (type === 'userMediaPermissionGranted') { - me.rejectMediaPermission_(error); - } else { - me.cachedMediaError_ = error; - me.sendEvent_('userMediaPermissionDenied'); - } - } - ); + // The hand-off is accepted once per request: the store echoes userMediaPermissionGranted down whenever the + // API reports a grant, and without this guard the echo would open a second stream. + if (me.mediaPermissionHandOffAccepted_) { + return; + } + me.mediaPermissionHandOffAccepted_ = true; + if (me.audioInputExchangeCompleted_) { + me.startLaterMediaPermissionRequest_(me.mediaPermissionRequestId_); + } else { + me.startFirstMediaPermissionRequest_(me.mediaPermissionRequestId_, type); + } + } else if (type === 'preferredAudioInputDevice') { + // Accepted only while awaited; a reply meant for an earlier request is ignored. + if (me.preferredAudioInputDeviceHandler_) { + me.preferredAudioInputDeviceHandler_(data.data && data.data.deviceId); + } } } }; diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index 533ab5f..195ea86 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -9,6 +9,23 @@ function testUserMediaPermissions() { airconsole.device_id = DEVICE_ID; // 2 = controller airconsole.devices[0] = {}; airconsole.devices[DEVICE_ID] = { uid: 1237, nicktype: 'Sergio', location: LOCATION, custom: {} }; + spyOn(navigator.mediaDevices, 'enumerateDevices').and.returnValue(Promise.resolve(FAKE_DEVICES)); + } + + const PERMISSION_TIMEOUT = 60001; + + const FAKE_DEVICES = [ + { kind: 'audioinput', deviceId: 'mic-builtin', label: 'iPhone Microphone', groupId: 'g1' }, + { kind: 'audioinput', deviceId: 'mic-car', label: 'CarPlay', groupId: 'g2' }, + { kind: 'audiooutput', deviceId: 'speaker', label: 'Speaker', groupId: 'g1' }, + ]; + + // Platform stand-in for sendEvent_: answers requestPreferredAudioInputDevice with no preference, as a non-car + // platform does, and ignores every other event. + function replyNoPreference(eventType) { + if (eventType === 'requestPreferredAudioInputDevice') { + dispatchCustomMessageEvent({ action: 'event', type: 'preferredAudioInputDevice', data: { deviceId: null } }); + } } function teardown() { @@ -25,16 +42,19 @@ function testUserMediaPermissions() { }); } - function makeFakeStream() { + function makeFakeStream(deviceId) { + const track = { + readyState: 'live', + stop: jasmine.createSpy('stop').and.callFake(function () { track.readyState = 'ended'; }), + getSettings: function () { return { deviceId: deviceId || 'mic-car' }; }, + }; return { + track: track, getAudioTracks: function() { - return [{}]; + return [track]; }, getTracks: function() { - return [{ - stop: () => { - } - }]; + return [track]; }, }; } @@ -56,7 +76,7 @@ function testUserMediaPermissions() { beforeEach(function () { jasmine.clock().install(); initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_'); + spyOn(airconsole, 'sendEvent_').and.callFake(replyNoPreference); }); afterEach(function () { @@ -215,13 +235,15 @@ function testUserMediaPermissions() { // Group 3: Timeout - it('Should reject with AirConsole.USER_MEDIA_ERROR_TYPE.timeout after 45000ms', function(done) { + it('Should reject with AirConsole.USER_MEDIA_ERROR_TYPE.timeout after 60000ms', function(done) { airconsole.getUserMedia({ audio: true }).catch(function(error) { expect(error.name).toBe("AirConsole.UserMediaError"); expect(error.message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); done(); }); - jasmine.clock().tick(45001); + jasmine.clock().tick(59999); + expect(airconsole.mediaPermissionPending_).toBe(true); + jasmine.clock().tick(2); }); }); @@ -237,6 +259,7 @@ function testUserMediaPermissions() { type: eventType, data: eventData }); + replyNoPreference(eventType); }); }); @@ -445,7 +468,7 @@ function testUserMediaPermissions() { describe('constraint forwarding to browser getUserMedia', function () { beforeEach(function () { initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_'); + spyOn(airconsole, 'sendEvent_').and.callFake(replyNoPreference); spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue( Promise.resolve(makeFakeStream()), ); @@ -484,6 +507,7 @@ function testUserMediaPermissions() { type: eventType, data: eventData }); + replyNoPreference(eventType); }); }); @@ -644,8 +668,8 @@ function testUserMediaPermissions() { // Trigger promptUserMediaPermission to start the browser flow dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); - // Fire the 45s timeout - jasmine.clock().tick(45001); + // Fire the 60s timeout + jasmine.clock().tick(60001); }); it('Should stop orphaned stream tracks when browser succeeds after timeout', function (done) { @@ -671,7 +695,7 @@ function testUserMediaPermissions() { }); dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); - jasmine.clock().tick(45001); + jasmine.clock().tick(60001); }); it('Should not set cachedMediaError_ when browser failure arrives after timeout', function (done) { @@ -693,7 +717,7 @@ function testUserMediaPermissions() { }); dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); - jasmine.clock().tick(45001); + jasmine.clock().tick(60001); }); }); @@ -702,7 +726,7 @@ function testUserMediaPermissions() { describe('stale message guard and error types', function () { beforeEach(function () { initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_'); + spyOn(airconsole, 'sendEvent_').and.callFake(replyNoPreference); }); afterEach(teardown); @@ -763,4 +787,336 @@ function testUserMediaPermissions() { dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionGranted' }); }); }); + // --- Group 15: Preferred audio input device exchange --- + // First request of a game: stream #1, the request with the device list, then keep / reopen / stream #2. + // Later request: no open on the hand-off, a list-less request, then a single open. + + describe('preferred audio input device exchange', function () { + var sent; + var reply; // undefined: no automatic reply; null or a device id: answered at once + + function eventsOfType(type) { + return sent.filter(function (e) { return e.type === type; }); + } + + async function settle() { + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } + } + + function handOff(type) { + dispatchCustomMessageEvent({ action: 'event', type: type || 'promptUserMediaPermission' }); + } + + function replyPreferred(deviceId) { + dispatchCustomMessageEvent({ action: 'event', type: 'preferredAudioInputDevice', data: { deviceId: deviceId } }); + } + + function updateScreen(deviceData) { + dispatchCustomMessageEvent({ action: 'update', device_id: AirConsole.SCREEN, device_data: deviceData }); + } + + function spyGetUserMediaSequence(results) { + var i = 0; + spyOn(navigator.mediaDevices, 'getUserMedia').and.callFake(function () { + const result = results[Math.min(i, results.length - 1)]; + i += 1; + return result instanceof Error || result instanceof DOMException ? Promise.reject(result) : result; + }); + } + + beforeEach(function () { + initAirConsoleAsController(); + sent = []; + reply = null; + spyOn(airconsole, 'sendEvent_').and.callFake(function (type, data) { + sent.push({ type: type, data: data }); + if (type === 'requestPreferredAudioInputDevice' && reply !== undefined) { + replyPreferred(reply); + } + }); + }); + + afterEach(teardown); + + describe('first request', function () { + it('Should ask with the device list and keep stream #1 on a null reply', async function () { + const stream1 = makeFakeStream('mic-car'); + spyGetUserMediaSequence([Promise.resolve(stream1)]); + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + expect(await result).toBe(stream1); + await settle(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + expect(eventsOfType('requestPreferredAudioInputDevice')[0].data).toEqual({ + devices: [ + { deviceId: 'mic-builtin', label: 'iPhone Microphone' }, + { deviceId: 'mic-car', label: 'CarPlay' }, + ], + activeDeviceId: 'mic-car', + }); + expect(eventsOfType('setAudioInputDevices')[0].data).toEqual({ + devices: [ + { deviceId: 'mic-builtin', label: 'iPhone Microphone' }, + { deviceId: 'mic-car', label: 'CarPlay' }, + ], + activeDeviceId: 'mic-car', + }); + expect(stream1.track.stop).not.toHaveBeenCalled(); + }); + + it('Should keep stream #1 when the reply names the device it is on', async function () { + reply = 'mic-car'; + const stream1 = makeFakeStream('mic-car'); + spyGetUserMediaSequence([Promise.resolve(stream1)]); + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + expect(await result).toBe(stream1); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + }); + + it('Should stop stream #1, then open stream #2 with the non-exact preferred deviceId', async function () { + reply = 'mic-builtin'; + const stream1 = makeFakeStream('mic-car'); + const stream2 = makeFakeStream('mic-builtin'); + spyGetUserMediaSequence([Promise.resolve(stream1), Promise.resolve(stream2)]); + const result = airconsole.getUserMedia({ audio: { echoCancellation: true } }); + handOff(); + expect(await result).toBe(stream2); + await settle(); + expect(stream1.track.stop).toHaveBeenCalled(); + expect(navigator.mediaDevices.getUserMedia.calls.argsFor(1)[0]).toEqual({ + audio: { echoCancellation: true, deviceId: 'mic-builtin' }, + }); + expect(eventsOfType('setAudioInputDevices')[0].data.activeDeviceId).toBe('mic-builtin'); + }); + + it('Should open a fresh stream when stream #1 ended before the reply', async function () { + reply = undefined; + const stream1 = makeFakeStream('mic-car'); + const fresh = makeFakeStream('mic-builtin'); + spyGetUserMediaSequence([Promise.resolve(stream1), Promise.resolve(fresh)]); + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + await settle(); + stream1.track.readyState = 'ended'; + replyPreferred(null); + expect(await result).toBe(fresh); + expect(navigator.mediaDevices.getUserMedia.calls.argsFor(1)[0]).toEqual({ audio: true }); + }); + + it('Should send userMediaRequestFailed(open-error) when stream #2 fails, and no setAudioInputDevices', async function () { + reply = 'mic-builtin'; + const openError = new DOMException('Busy', 'NotReadableError'); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), openError]); + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + await expectAsync(result).toBeRejectedWith(openError); + await settle(); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ + reason: 'open-error', + error: 'NotReadableError', + }); + expect(eventsOfType('setAudioInputDevices').length).toBe(0); + }); + + it('Should send userMediaRequestFailed(open-error) when stream #1 fails after the native grant', async function () { + const openError = new DOMException('No device', 'NotFoundError'); + spyGetUserMediaSequence([openError]); + const result = airconsole.getUserMedia({ audio: true }); + handOff('userMediaPermissionGranted'); + await expectAsync(result).toBeRejectedWith(openError); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ reason: 'open-error', error: 'NotFoundError' }); + expect(eventsOfType('userMediaPermissionDenied').length).toBe(0); + }); + + it('Should accept the permission hand-off only once per request', async function () { + reply = undefined; + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car'))]); + airconsole.getUserMedia({ audio: true }).catch(function () {}); + handOff('promptUserMediaPermission'); + await settle(); + handOff('userMediaPermissionGranted'); + await settle(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + }); + + it('Should ignore a preferredAudioInputDevice that is not awaited', async function () { + reply = undefined; + const stream1 = makeFakeStream('mic-car'); + spyGetUserMediaSequence([Promise.resolve(stream1), Promise.resolve(makeFakeStream('stray'))]); + const result = airconsole.getUserMedia({ audio: true }); + replyPreferred('stray'); + handOff(); + await settle(); + replyPreferred(null); + replyPreferred('stray'); + expect(await result).toBe(stream1); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + }); + }); + + describe('later request', function () { + async function completeFirstRequest() { + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + await result; + await settle(); + sent = []; + } + + it('Should not open on the hand-off, ask without a device list, then open once with the saved device', async function () { + const later = makeFakeStream('mic-builtin'); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), Promise.resolve(later)]); + await completeFirstRequest(); + + reply = undefined; + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + await settle(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + expect(eventsOfType('requestPreferredAudioInputDevice')[0].data).toEqual({}); + + replyPreferred('mic-builtin'); + expect(await result).toBe(later); + await settle(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(2); + expect(navigator.mediaDevices.getUserMedia.calls.argsFor(1)[0]).toEqual({ audio: { deviceId: 'mic-builtin' } }); + expect(eventsOfType('setAudioInputDevices')[0].data.activeDeviceId).toBe('mic-builtin'); + }); + + it('Should take the denial path on NotAllowedError', async function () { + const notAllowed = makeNotAllowedError(); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), notAllowed]); + await completeFirstRequest(); + + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + await settle(); + expect(eventsOfType('userMediaPermissionDenied').length).toBe(1); + expect(eventsOfType('userMediaRequestFailed').length).toBe(0); + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionDenied' }); + await expectAsync(result).toBeRejectedWith(notAllowed); + }); + + it('Should send userMediaRequestFailed(open-error) on any other error', async function () { + const busy = new DOMException('Busy', 'NotReadableError'); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), busy]); + await completeFirstRequest(); + + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + await expectAsync(result).toBeRejectedWith(busy); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ reason: 'open-error', error: 'NotReadableError' }); + }); + }); + + describe('permission timeout', function () { + beforeEach(function () { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(2026, 0, 1)); + }); + + afterEach(function () { + jasmine.clock().uninstall(); + }); + + it('Should stop stream #1 and send userMediaRequestFailed(timeout) before rejecting', async function () { + reply = undefined; + const stream1 = makeFakeStream('mic-car'); + spyGetUserMediaSequence([Promise.resolve(stream1)]); + const order = []; + airconsole.sendEvent_.and.callFake(function (type, data) { + sent.push({ type: type, data: data }); + order.push(type); + }); + const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { + order.push('rejected'); + return error; + }); + handOff(); + await settle(); + jasmine.clock().tick(PERMISSION_TIMEOUT); + const error = await result; + expect(error.message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + expect(stream1.track.stop).toHaveBeenCalled(); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ reason: 'timeout', error: 'Timeout' }); + expect(order.indexOf('userMediaRequestFailed')).toBeLessThan(order.indexOf('rejected')); + }); + + it('Should use the first-request flow again after a timeout before the reply, and ignore the late reply', async function () { + reply = undefined; + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car'))]); + const first = airconsole.getUserMedia({ audio: true }).catch(function () {}); + handOff(); + await settle(); + jasmine.clock().tick(PERMISSION_TIMEOUT); + await first; + replyPreferred('mic-builtin'); // crosses the timeout: ignored + + airconsole.getUserMedia({ audio: true }).catch(function () {}); + handOff(); + await settle(); + // First-request flow: opens on the hand-off and asks with a device list. + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(2); + expect(eventsOfType('requestPreferredAudioInputDevice')[1].data.devices.length).toBe(2); + }); + + it('Should use the later-request flow after a timeout that came after the reply', async function () { + reply = 'mic-builtin'; + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), new Promise(function () {})]); + const first = airconsole.getUserMedia({ audio: true }).catch(function () {}); + handOff(); + await settle(); + jasmine.clock().tick(PERMISSION_TIMEOUT); + await first; + + reply = undefined; + airconsole.getUserMedia({ audio: true }).catch(function () {}); + handOff(); + await settle(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(2); + expect(eventsOfType('requestPreferredAudioInputDevice')[1].data).toEqual({}); + }); + + it('Should stop the clock while the screen is paused and keep the remaining time', async function () { + const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); + jasmine.clock().tick(30000); + updateScreen({ paused: { state: true, reason: 'visibility' } }); + jasmine.clock().tick(120000); + await settle(); + expect(airconsole.mediaPermissionPending_).toBe(true); + updateScreen({ paused: { state: false, reason: 'resume' } }); + jasmine.clock().tick(29999); + expect(airconsole.mediaPermissionPending_).toBe(true); + jasmine.clock().tick(2); + expect((await result).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + }); + + it('Should wait with the clock stopped when the request starts while the screen is paused', async function () { + airconsole.devices[AirConsole.SCREEN] = { paused: { state: true, reason: 'audio-focus-loss' } }; + const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); + jasmine.clock().tick(120000); + expect(airconsole.mediaPermissionPending_).toBe(true); + updateScreen({ paused: { state: false, reason: 'resume' } }); + jasmine.clock().tick(PERMISSION_TIMEOUT); + expect((await result).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + }); + + it('Should run normally on a screen that has never paused', async function () { + airconsole.devices[AirConsole.SCREEN] = {}; + const first = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); + updateScreen({ location: LOCATION }); + jasmine.clock().tick(PERMISSION_TIMEOUT); + expect((await first).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + + airconsole.devices[AirConsole.SCREEN] = undefined; + const second = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); + expect(airconsole.mediaPermissionPending_).toBe(true); + jasmine.clock().tick(PERMISSION_TIMEOUT); + expect((await second).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + }); + }); + }); } From 37d55528bd3b6d62e4bc6c394d58739776617369 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 15:10:35 +0000 Subject: [PATCH 02/11] ! F: Run the preferred audio input device exchange only when ready announces it The exchange is for car head units. Without preferredAudioInputDeviceSupported in ready, getUserMedia opens on the hand-off with the 45 s timeout, as at fe5da45, and sends none of the new events. The base specs run unchanged without the flag. #skip-tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- CHANGELOG.md | 5 +- airconsole-1.11.0.js | 75 +++++++++++++++-- .../methods/spec-usermedia-permissions.js | 84 ++++++++++++++----- 3 files changed, 135 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d370682..47d1faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,13 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ preferred device; later requests open once with the device merged in as a non-exact `deviceId`. - The API reports the audio inputs and the active device after every successful open (`setAudioInputDevices`), and failures the platform would not otherwise learn of (`userMediaRequestFailed`). +- The preferred audio input device exchange runs only when the platform announces it in `ready` + (`preferredAudioInputDeviceSupported: true`); otherwise `getUserMedia` behaves as before. ### Changed -- The `getUserMedia` timeout is now 60 s of unpaused time: it stops while the screen is paused. +- With the exchange, the `getUserMedia` timeout is 60 s of unpaused time: it stops while the screen is paused. + Without it, the timeout stays 45 s. ## [1.11.0] - 2026-07-07 diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index 59e3689..e513281 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -815,6 +815,13 @@ const mediaPermissionCallbacks_ = new WeakMap(); */ const PERMISSION_TIMEOUT_MS = 60000; +/** + * The getUserMedia timeout when the platform does not announce the preferred audio input device exchange: counted in + * wall-clock time, as before the exchange existed. + * @private + */ +const BASE_PERMISSION_TIMEOUT_MS = 45000; + /** * @typedef {Object} AirConsole~GetUserMediaConstraint * @property {boolean} audio - Whether to request microphone permissions. Must be provided; video is not supported. @@ -836,9 +843,10 @@ const PERMISSION_TIMEOUT_MS = 60000; * Note: callers are responsible for stopping stream tracks when the stream is no longer needed: * `stream.getTracks().forEach(function(t) { t.stop(); })` * - * The platform may prefer another audio input (e.g. the phone's built-in microphone on a car head unit); the - * API then resolves with a stream on that device. A request that stays unsettled for 60 s of unpaused time - * rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock stops while the screen is paused. + * On a platform that announces it (a car head unit), the platform may prefer another audio input, e.g. the + * phone's built-in microphone; the API then resolves with a stream on that device. There a request that stays + * unsettled for 60 s of unpaused time rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock + * stops while the screen is paused. Everywhere else the timeout is 45 s. * * @example * airconsole.getUserMedia({ audio: true }).then(function(stream) { @@ -884,9 +892,17 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { me.mediaPermissionHandOffAccepted_ = false; me.preferredAudioInputDeviceHandler_ = null; me.mediaPermissionStreams_ = []; + // Taken per request, so a later ready cannot switch flows while a request is pending. + me.mediaPermissionUsesExchange_ = me.preferredAudioInputDeviceSupported_ === true; mediaPermissionCallbacks_.set(me, { resolve: resolve, reject: reject }); - me.mediaPermissionRemainingMs_ = PERMISSION_TIMEOUT_MS; - me.updatePermissionClock_(); + if (me.mediaPermissionUsesExchange_) { + me.mediaPermissionRemainingMs_ = PERMISSION_TIMEOUT_MS; + me.updatePermissionClock_(); + } else { + me.mediaPermissionTimeout_ = setTimeout(function() { + me.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); + }, BASE_PERMISSION_TIMEOUT_MS); + } // 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. @@ -903,6 +919,7 @@ AirConsole.prototype.cleanUpMediaPermission_ = function cleanUpMediaPermission_( this.mediaPermissionHandOffAccepted_ = false; this.preferredAudioInputDeviceHandler_ = null; this.mediaPermissionStreams_ = []; + this.mediaPermissionUsesExchange_ = false; this.cachedMediaError_ = null; mediaPermissionCallbacks_.delete(this); } @@ -933,12 +950,13 @@ AirConsole.prototype.isScreenPaused_ = function isScreenPaused_() { /** * Starts or stops the permission clock to match the screen's pause state, keeping the remaining time across a pause. - * Called when a request starts and whenever the screen's presence changes. + * Called when a request starts and whenever the screen's presence changes. Does nothing for a request without the + * exchange, whose timeout is the base one. * @private */ AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() { var me = this; - if (!me.mediaPermissionPending_) { + if (!me.mediaPermissionPending_ || !me.mediaPermissionUsesExchange_) { return; } const running = me.mediaPermissionTimeout_ !== undefined; @@ -973,6 +991,44 @@ AirConsole.prototype.expireMediaPermission_ = function expireMediaPermission_() this.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); }; +/** + * Opens the stream on the permission hand-off, as before the preferred audio input device exchange existed. Used when + * the platform does not announce the exchange. + * @private + * @param {string} type - 'userMediaPermissionGranted' or 'promptUserMediaPermission' + */ +AirConsole.prototype.startBaseMediaPermissionRequest_ = function startBaseMediaPermissionRequest_(type) { + var me = this; + navigator.mediaDevices.getUserMedia(me.mediaPermissionConstraints_).then( + function success(stream) { + if (!me.mediaPermissionPending_) { + // Timeout fired while the browser permission dialog was still open; + // the outer Promise is already settled — stop the orphaned stream to release hardware. + stream.getTracks().forEach(function (t) { t.stop(); }); + return; + } + // 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 the event branch. + me.sendEvent_('userMediaPermissionGranted', { + constraints: me.mediaPermissionConstraints_, + }); + me.resolveMediaPermission_(stream); + }, + function failure(error) { + if (!me.mediaPermissionPending_) { + return; + } + // Native controller: platform already granted permission but stream open failed. + if (type === 'userMediaPermissionGranted') { + me.rejectMediaPermission_(error); + } else { + me.cachedMediaError_ = error; + me.sendEvent_('userMediaPermissionDenied'); + } + }); +}; + /** * Returns true if requestId is the request that is still pending. * @private @@ -1931,6 +1987,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === "ready") { me.device_id = data.device_id; me.devices = data.devices; + me.preferredAudioInputDeviceSupported_ = data.preferredAudioInputDeviceSupported === true; me.updatePermissionClock_(); if (me.server_time_offset !== false) { me.server_time_offset = data.server_time_offset || 0; @@ -2047,6 +2104,10 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.rejectMediaPermission_(error); } } else if (type === 'userMediaPermissionGranted' || type === 'promptUserMediaPermission') { + if (!me.mediaPermissionUsesExchange_) { + me.startBaseMediaPermissionRequest_(type); + return; + } // The hand-off is accepted once per request: the store echoes userMediaPermissionGranted down whenever the // API reports a grant, and without this guard the echo would open a second stream. if (me.mediaPermissionHandOffAccepted_) { diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index 195ea86..fbe44d8 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -20,14 +20,6 @@ function testUserMediaPermissions() { { kind: 'audiooutput', deviceId: 'speaker', label: 'Speaker', groupId: 'g1' }, ]; - // Platform stand-in for sendEvent_: answers requestPreferredAudioInputDevice with no preference, as a non-car - // platform does, and ignores every other event. - function replyNoPreference(eventType) { - if (eventType === 'requestPreferredAudioInputDevice') { - dispatchCustomMessageEvent({ action: 'event', type: 'preferredAudioInputDevice', data: { deviceId: null } }); - } - } - function teardown() { if (airconsole) { window.removeEventListener('message', airconsole.messageEventListener_); @@ -76,7 +68,7 @@ function testUserMediaPermissions() { beforeEach(function () { jasmine.clock().install(); initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_').and.callFake(replyNoPreference); + spyOn(airconsole, 'sendEvent_'); }); afterEach(function () { @@ -235,15 +227,13 @@ function testUserMediaPermissions() { // Group 3: Timeout - it('Should reject with AirConsole.USER_MEDIA_ERROR_TYPE.timeout after 60000ms', function(done) { + it('Should reject with AirConsole.USER_MEDIA_ERROR_TYPE.timeout after 45000ms', function(done) { airconsole.getUserMedia({ audio: true }).catch(function(error) { expect(error.name).toBe("AirConsole.UserMediaError"); expect(error.message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); done(); }); - jasmine.clock().tick(59999); - expect(airconsole.mediaPermissionPending_).toBe(true); - jasmine.clock().tick(2); + jasmine.clock().tick(45001); }); }); @@ -259,7 +249,6 @@ function testUserMediaPermissions() { type: eventType, data: eventData }); - replyNoPreference(eventType); }); }); @@ -468,7 +457,7 @@ function testUserMediaPermissions() { describe('constraint forwarding to browser getUserMedia', function () { beforeEach(function () { initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_').and.callFake(replyNoPreference); + spyOn(airconsole, 'sendEvent_'); spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue( Promise.resolve(makeFakeStream()), ); @@ -507,7 +496,6 @@ function testUserMediaPermissions() { type: eventType, data: eventData }); - replyNoPreference(eventType); }); }); @@ -668,8 +656,8 @@ function testUserMediaPermissions() { // Trigger promptUserMediaPermission to start the browser flow dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); - // Fire the 60s timeout - jasmine.clock().tick(60001); + // Fire the 45s timeout + jasmine.clock().tick(45001); }); it('Should stop orphaned stream tracks when browser succeeds after timeout', function (done) { @@ -695,7 +683,7 @@ function testUserMediaPermissions() { }); dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); - jasmine.clock().tick(60001); + jasmine.clock().tick(45001); }); it('Should not set cachedMediaError_ when browser failure arrives after timeout', function (done) { @@ -717,7 +705,7 @@ function testUserMediaPermissions() { }); dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); - jasmine.clock().tick(60001); + jasmine.clock().tick(45001); }); }); @@ -726,7 +714,7 @@ function testUserMediaPermissions() { describe('stale message guard and error types', function () { beforeEach(function () { initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_').and.callFake(replyNoPreference); + spyOn(airconsole, 'sendEvent_'); }); afterEach(teardown); @@ -787,6 +775,7 @@ function testUserMediaPermissions() { dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionGranted' }); }); }); + // --- Group 15: Preferred audio input device exchange --- // First request of a game: stream #1, the request with the device list, then keep / reopen / stream #2. // Later request: no open on the hand-off, a list-less request, then a single open. @@ -828,6 +817,8 @@ function testUserMediaPermissions() { beforeEach(function () { initAirConsoleAsController(); + // The platform announced the exchange in ready (see "ready flag" below). + airconsole.preferredAudioInputDeviceSupported_ = true; sent = []; reply = null; spyOn(airconsole, 'sendEvent_').and.callFake(function (type, data) { @@ -840,6 +831,57 @@ function testUserMediaPermissions() { afterEach(teardown); + describe('ready flag', function () { + function dispatchReady(extra) { + dispatchCustomMessageEvent(Object.assign({ + action: 'ready', + code: 1237, + device_id: DEVICE_ID, + devices: [{}, undefined, airconsole.devices[DEVICE_ID]], + }, extra)); + } + + it('Should leave the exchange off when ready does not announce it', function () { + dispatchReady({}); + expect(airconsole.preferredAudioInputDeviceSupported_).toBe(false); + }); + + it('Should turn the exchange on when ready announces it', function () { + airconsole.preferredAudioInputDeviceSupported_ = false; + dispatchReady({ preferredAudioInputDeviceSupported: true }); + expect(airconsole.preferredAudioInputDeviceSupported_).toBe(true); + }); + + it('Should open on the hand-off and send none of the new events without the flag', async function () { + airconsole.preferredAudioInputDeviceSupported_ = false; + const stream = makeFakeStream('mic-car'); + spyGetUserMediaSequence([Promise.resolve(stream)]); + const result = airconsole.getUserMedia({ audio: true }); + handOff(); + expect(await result).toBe(stream); + await settle(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + expect(navigator.mediaDevices.enumerateDevices).not.toHaveBeenCalled(); + expect(eventsOfType('requestPreferredAudioInputDevice').length).toBe(0); + expect(eventsOfType('setAudioInputDevices').length).toBe(0); + expect(eventsOfType('userMediaRequestFailed').length).toBe(0); + }); + + it('Should keep the 45 s timeout running through a screen pause without the flag', async function () { + jasmine.clock().install(); + try { + airconsole.preferredAudioInputDeviceSupported_ = false; + const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); + updateScreen({ paused: { state: true, reason: 'audio-focus-loss' } }); + jasmine.clock().tick(45001); + expect((await result).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + expect(eventsOfType('userMediaRequestFailed').length).toBe(0); + } finally { + jasmine.clock().uninstall(); + } + }); + }); + describe('first request', function () { it('Should ask with the device list and keep stream #1 on a null reply', async function () { const stream1 = makeFakeStream('mic-car'); From d74415bc46ee3902c92db6818b25817c4b4c82f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 16:55:12 +0000 Subject: [PATCH 03/11] ! B: End a pending getUserMedia request on destroy() like a timeout On a first request the API holds stream #1 while it waits for the preferred device. destroy() rejected without stopping it, so the microphone, possibly the car's, stayed on, and sent nothing, so the platform's prompts stayed open. With the exchange, destroy() now stops held streams, sends userMediaRequestFailed and then rejects. Without the flag it is unchanged. #skip-tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- CHANGELOG.md | 2 ++ airconsole-1.11.0.js | 7 +++- .../methods/spec-usermedia-permissions.js | 34 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d1faa..1f3daf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ - With the exchange, the `getUserMedia` timeout is 60 s of unpaused time: it stops while the screen is paused. Without it, the timeout stays 45 s. +- With the exchange, `destroy()` ends a pending `getUserMedia` request like a timeout: it stops any stream the request + holds and reports `userMediaRequestFailed` before rejecting. ## [1.11.0] - 2026-07-07 diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index e513281..759311f 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -1296,7 +1296,12 @@ AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMedi */ AirConsole.prototype.destroy = function destroy() { window.removeEventListener('message', this.messageEventListener_); - if (this.mediaPermissionPending_) { + if (this.mediaPermissionPending_ && this.mediaPermissionUsesExchange_) { + // End the request like a timeout: stop any stream it holds (stream #1 while the reply is awaited), tell the + // platform so it closes its prompts, then reject. The timer is cleared first, so it cannot expire a second time. + clearTimeout(this.mediaPermissionTimeout_); + this.expireMediaPermission_(); + } else if (this.mediaPermissionPending_) { this.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); } else if (this.mediaPermissionTimeout_) { clearTimeout(this.mediaPermissionTimeout_); diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index fbe44d8..fab0ed3 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -880,6 +880,14 @@ function testUserMediaPermissions() { jasmine.clock().uninstall(); } }); + + it('Should reject on destroy() without userMediaRequestFailed when the flag is off', async function () { + airconsole.preferredAudioInputDeviceSupported_ = false; + const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); + airconsole.destroy(); + expect((await result).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + expect(eventsOfType('userMediaRequestFailed').length).toBe(0); + }); }); describe('first request', function () { @@ -1087,6 +1095,32 @@ function testUserMediaPermissions() { expect(order.indexOf('userMediaRequestFailed')).toBeLessThan(order.indexOf('rejected')); }); + it('Should end a request on destroy() like a timeout: stop stream #1, send, then reject, once', async function () { + reply = undefined; + const stream1 = makeFakeStream('mic-car'); + spyGetUserMediaSequence([Promise.resolve(stream1)]); + const order = []; + airconsole.sendEvent_.and.callFake(function (type, data) { + sent.push({ type: type, data: data }); + order.push(type); + }); + const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { + order.push('rejected'); + return error; + }); + handOff(); + await settle(); + expect(eventsOfType('requestPreferredAudioInputDevice').length).toBe(1); + airconsole.destroy(); + const error = await result; + expect(error.message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); + expect(stream1.track.stop).toHaveBeenCalled(); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ reason: 'timeout', error: 'Timeout' }); + expect(order.indexOf('userMediaRequestFailed')).toBeLessThan(order.indexOf('rejected')); + jasmine.clock().tick(PERMISSION_TIMEOUT); + expect(eventsOfType('userMediaRequestFailed').length).toBe(1); + }); + it('Should use the first-request flow again after a timeout before the reply, and ignore the late reply', async function () { reply = undefined; spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car'))]); From 5fd5a84bed0f13ce0f5b9348ce3590c4a7e18783 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:18:51 +0000 Subject: [PATCH 04/11] ^ R: Rename setAudioInputDevices to audioInputDevicesReported The event is a notification from the API to the platform, never answered; the old name read like a setter. No behaviour change. #skip-tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- CHANGELOG.md | 2 +- airconsole-1.11.0.js | 2 +- tests/spec/methods/spec-usermedia-permissions.js | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3daf6..7331663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ (`requestPreferredAudioInputDevice` / `preferredAudioInputDevice`), so car head units can keep games on the phone's built-in microphone. The first request of a game opens once, then keeps that stream, reopens it, or switches to the preferred device; later requests open once with the device merged in as a non-exact `deviceId`. -- The API reports the audio inputs and the active device after every successful open (`setAudioInputDevices`), and +- The API reports the audio inputs and the active device after every successful open (`audioInputDevicesReported`), and failures the platform would not otherwise learn of (`userMediaRequestFailed`). - The preferred audio input device exchange runs only when the platform announces it in `ready` (`preferredAudioInputDeviceSupported: true`); otherwise `getUserMedia` behaves as before. diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index 759311f..2586732 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -1159,7 +1159,7 @@ AirConsole.prototype.resolveMediaPermissionWithReport_ = function resolveMediaPe }); me.resolveMediaPermission_(stream); AirConsole.enumerateAudioInputDevices_().then(function (devices) { - me.sendEvent_('setAudioInputDevices', { + me.sendEvent_('audioInputDevicesReported', { devices: devices, activeDeviceId: AirConsole.getActiveAudioInputDeviceId_(stream), }); diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index fab0ed3..392c857 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -863,7 +863,7 @@ function testUserMediaPermissions() { expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); expect(navigator.mediaDevices.enumerateDevices).not.toHaveBeenCalled(); expect(eventsOfType('requestPreferredAudioInputDevice').length).toBe(0); - expect(eventsOfType('setAudioInputDevices').length).toBe(0); + expect(eventsOfType('audioInputDevicesReported').length).toBe(0); expect(eventsOfType('userMediaRequestFailed').length).toBe(0); }); @@ -906,7 +906,7 @@ function testUserMediaPermissions() { ], activeDeviceId: 'mic-car', }); - expect(eventsOfType('setAudioInputDevices')[0].data).toEqual({ + expect(eventsOfType('audioInputDevicesReported')[0].data).toEqual({ devices: [ { deviceId: 'mic-builtin', label: 'iPhone Microphone' }, { deviceId: 'mic-car', label: 'CarPlay' }, @@ -939,7 +939,7 @@ function testUserMediaPermissions() { expect(navigator.mediaDevices.getUserMedia.calls.argsFor(1)[0]).toEqual({ audio: { echoCancellation: true, deviceId: 'mic-builtin' }, }); - expect(eventsOfType('setAudioInputDevices')[0].data.activeDeviceId).toBe('mic-builtin'); + expect(eventsOfType('audioInputDevicesReported')[0].data.activeDeviceId).toBe('mic-builtin'); }); it('Should open a fresh stream when stream #1 ended before the reply', async function () { @@ -956,7 +956,7 @@ function testUserMediaPermissions() { expect(navigator.mediaDevices.getUserMedia.calls.argsFor(1)[0]).toEqual({ audio: true }); }); - it('Should send userMediaRequestFailed(open-error) when stream #2 fails, and no setAudioInputDevices', async function () { + it('Should send userMediaRequestFailed(open-error) when stream #2 fails, and no audioInputDevicesReported', async function () { reply = 'mic-builtin'; const openError = new DOMException('Busy', 'NotReadableError'); spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), openError]); @@ -968,7 +968,7 @@ function testUserMediaPermissions() { reason: 'open-error', error: 'NotReadableError', }); - expect(eventsOfType('setAudioInputDevices').length).toBe(0); + expect(eventsOfType('audioInputDevicesReported').length).toBe(0); }); it('Should send userMediaRequestFailed(open-error) when stream #1 fails after the native grant', async function () { @@ -1033,7 +1033,7 @@ function testUserMediaPermissions() { await settle(); expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledTimes(2); expect(navigator.mediaDevices.getUserMedia.calls.argsFor(1)[0]).toEqual({ audio: { deviceId: 'mic-builtin' } }); - expect(eventsOfType('setAudioInputDevices')[0].data.activeDeviceId).toBe('mic-builtin'); + expect(eventsOfType('audioInputDevicesReported')[0].data.activeDeviceId).toBe('mic-builtin'); }); it('Should take the denial path on NotAllowedError', async function () { From bdd5c7b4aa3c5826ce26b8f0ba893d97db14687b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:03:17 +0000 Subject: [PATCH 05/11] ! B: Apply one NotAllowedError rule to every open after stream #1 On web a NotAllowedError is a permission denial and takes the base's denial path for a fresh open, stream #2 and a later request's open alike. In the app the native app has just granted the permission, so any failed open reaches the game as userMediaRequestFailed(open-error) instead of the store's web overlay. #skip-tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- airconsole-1.11.0.js | 43 +++++++++++++------ .../methods/spec-usermedia-permissions.js | 41 +++++++++++++++++- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index 2586732..440f3b3 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -1184,6 +1184,29 @@ AirConsole.prototype.failMediaPermissionOpen_ = function failMediaPermissionOpen this.rejectMediaPermission_(error); }; +/** + * Ends the pending request after an open that followed stream #1 failed: a fresh open or stream #2 on a first + * request, or a later request's single open. On web a NotAllowedError is a permission denial and takes the base's + * denial path. Any other error, and every error in the app, where the native app has just granted the permission, + * reaches the game through failMediaPermissionOpen_. + * @private + * @param {number} requestId + * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). + * @param {*} error + */ +AirConsole.prototype.failMediaPermissionReopen_ = function failMediaPermissionReopen_(requestId, handOffType, + error) { + if (!this.isCurrentMediaPermission_(requestId)) { + return; + } + if (handOffType === 'promptUserMediaPermission' && error && error.name === 'NotAllowedError') { + this.cachedMediaError_ = error; + this.sendEvent_('userMediaPermissionDenied'); + } else { + this.failMediaPermissionOpen_(requestId, error); + } +}; + /** * Opens a stream for the pending request, holding it until it is handed to the game. * @private @@ -1239,7 +1262,7 @@ AirConsole.prototype.startFirstMediaPermissionRequest_ = function startFirstMedi me.resolveMediaPermissionWithReport_(requestId, stream); } }, function (error) { - me.failMediaPermissionOpen_(requestId, error); + me.failMediaPermissionReopen_(requestId, handOffType, error); }); }); }); @@ -1262,8 +1285,10 @@ AirConsole.prototype.startFirstMediaPermissionRequest_ = function startFirstMedi * once with it merged in as a non-exact constraint. * @private * @param {number} requestId + * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). */ -AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMediaPermissionRequest_(requestId) { +AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMediaPermissionRequest_(requestId, + handOffType) { var me = this; const constraints = me.mediaPermissionConstraints_; me.requestPreferredAudioInputDevice_(requestId, {}, function (deviceId) { @@ -1273,16 +1298,8 @@ AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMedi me.resolveMediaPermissionWithReport_(requestId, stream); } }, function (error) { - if (!me.isCurrentMediaPermission_(requestId)) { - return; - } - if (error && error.name === 'NotAllowedError') { - // The permission was revoked: a permission denial, which takes the base's denial path. - me.cachedMediaError_ = error; - me.sendEvent_('userMediaPermissionDenied'); - } else { - me.failMediaPermissionOpen_(requestId, error); - } + // On web a revoked permission is first seen here, as a NotAllowedError. + me.failMediaPermissionReopen_(requestId, handOffType, error); }); }); }; @@ -2120,7 +2137,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } me.mediaPermissionHandOffAccepted_ = true; if (me.audioInputExchangeCompleted_) { - me.startLaterMediaPermissionRequest_(me.mediaPermissionRequestId_); + me.startLaterMediaPermissionRequest_(me.mediaPermissionRequestId_, type); } else { me.startFirstMediaPermissionRequest_(me.mediaPermissionRequestId_, type); } diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index 392c857..286d893 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -971,6 +971,31 @@ function testUserMediaPermissions() { expect(eventsOfType('audioInputDevicesReported').length).toBe(0); }); + it('Should take the denial path when stream #2 fails with NotAllowedError on web', async function () { + reply = 'mic-builtin'; + const notAllowed = makeNotAllowedError(); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), notAllowed]); + const result = airconsole.getUserMedia({ audio: true }); + handOff('promptUserMediaPermission'); + await settle(); + expect(eventsOfType('userMediaPermissionDenied').length).toBe(1); + expect(eventsOfType('userMediaRequestFailed').length).toBe(0); + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionDenied' }); + await expectAsync(result).toBeRejectedWith(notAllowed); + }); + + it('Should send userMediaRequestFailed(open-error) when stream #2 fails with NotAllowedError in the app', async function () { + reply = 'mic-builtin'; + const notAllowed = makeNotAllowedError(); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), notAllowed]); + const result = airconsole.getUserMedia({ audio: true }); + handOff('userMediaPermissionGranted'); + await expectAsync(result).toBeRejectedWith(notAllowed); + await settle(); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ reason: 'open-error', error: 'NotAllowedError' }); + expect(eventsOfType('userMediaPermissionDenied').length).toBe(0); + }); + it('Should send userMediaRequestFailed(open-error) when stream #1 fails after the native grant', async function () { const openError = new DOMException('No device', 'NotFoundError'); spyGetUserMediaSequence([openError]); @@ -1008,9 +1033,9 @@ function testUserMediaPermissions() { }); describe('later request', function () { - async function completeFirstRequest() { + async function completeFirstRequest(type) { const result = airconsole.getUserMedia({ audio: true }); - handOff(); + handOff(type); await result; await settle(); sent = []; @@ -1050,6 +1075,18 @@ function testUserMediaPermissions() { await expectAsync(result).toBeRejectedWith(notAllowed); }); + it('Should send userMediaRequestFailed(open-error) on NotAllowedError in the app, with no denial', async function () { + const notAllowed = makeNotAllowedError(); + spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), notAllowed]); + await completeFirstRequest('userMediaPermissionGranted'); + + const result = airconsole.getUserMedia({ audio: true }); + handOff('userMediaPermissionGranted'); + await expectAsync(result).toBeRejectedWith(notAllowed); + expect(eventsOfType('userMediaRequestFailed')[0].data).toEqual({ reason: 'open-error', error: 'NotAllowedError' }); + expect(eventsOfType('userMediaPermissionDenied').length).toBe(0); + }); + it('Should send userMediaRequestFailed(open-error) on any other error', async function () { const busy = new DOMException('Busy', 'NotReadableError'); spyGetUserMediaSequence([Promise.resolve(makeFakeStream('mic-car')), busy]); From d8be6d3f6bc0e7bcfed8f42865e0325c6b500a67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:39:51 +0000 Subject: [PATCH 06/11] . D: Drop the store echo from the hand-off guard comment The platform no longer echoes userMediaPermissionGranted down, so the guard only keeps a duplicate hand-off from opening a second stream. #skip-tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- airconsole-1.11.0.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index 440f3b3..61e4416 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -2130,8 +2130,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.startBaseMediaPermissionRequest_(type); return; } - // The hand-off is accepted once per request: the store echoes userMediaPermissionGranted down whenever the - // API reports a grant, and without this guard the echo would open a second stream. + // The hand-off is accepted once per request, so a duplicate hand-off cannot open a second stream. if (me.mediaPermissionHandOffAccepted_) { return; } From 18a968a18eef5a49306d7f80aa721f6802e88bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:20:29 +0000 Subject: [PATCH 07/11] . F: Port the preferred audio input exchange to the 1.12.0 beta The 1.12.0 beta (renamed from 1.11.1 on master) runs the shared spec-usermedia-permissions.js, which tests the exchange added to 1.11.0. Apply the same changes to beta/airconsole-1.12.0.js so both versions behave alike and every API suite passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- beta/airconsole-1.12.0.js | 471 +++++++++++++++++++++++++++++++++++--- 1 file changed, 438 insertions(+), 33 deletions(-) diff --git a/beta/airconsole-1.12.0.js b/beta/airconsole-1.12.0.js index 5504371..d706e26 100644 --- a/beta/airconsole-1.12.0.js +++ b/beta/airconsole-1.12.0.js @@ -806,6 +806,22 @@ AirConsole.prototype.onUserMediaAccessDenied = function (device_id) {}; */ const mediaPermissionCallbacks_ = new WeakMap(); +/** + * How long a getUserMedia request may stay unsettled, counted in unpaused time: the clock stops while the screen is + * paused. It covers everything a request waits on: the car-connection notice, the permission flow, the preferred + * audio input device exchange (including the built-in microphone requirement and the picker), the open and the web + * denial overlay. + * @private + */ +const PERMISSION_TIMEOUT_MS = 60000; + +/** + * The getUserMedia timeout when the platform does not announce the preferred audio input device exchange: counted in + * wall-clock time, as before the exchange existed. + * @private + */ +const BASE_PERMISSION_TIMEOUT_MS = 45000; + /** * @typedef {Object} AirConsole~GetUserMediaConstraint * @property {boolean} audio - Whether to request microphone permissions. Must be provided; video is not supported. @@ -827,6 +843,11 @@ const mediaPermissionCallbacks_ = new WeakMap(); * Note: callers are responsible for stopping stream tracks when the stream is no longer needed: * `stream.getTracks().forEach(function(t) { t.stop(); })` * + * On a platform that announces it (a car head unit), the platform may prefer another audio input, e.g. the + * phone's built-in microphone; the API then resolves with a stream on that device. There a request that stays + * unsettled for 60 s of unpaused time rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock + * stops while the screen is paused. Everywhere else the timeout is 45 s. + * * @example * airconsole.getUserMedia({ audio: true }).then(function(stream) { * console.info('Media access granted', stream); @@ -867,10 +888,21 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { return new Promise(function (resolve, reject) { me.mediaPermissionConstraints_ = constraints; me.mediaPermissionPending_ = true; + me.mediaPermissionRequestId_ = (me.mediaPermissionRequestId_ || 0) + 1; + me.mediaPermissionHandOffAccepted_ = false; + me.preferredAudioInputDeviceHandler_ = null; + me.mediaPermissionStreams_ = []; + // Taken per request, so a later ready cannot switch flows while a request is pending. + me.mediaPermissionUsesExchange_ = me.preferredAudioInputDeviceSupported_ === true; mediaPermissionCallbacks_.set(me, { resolve: resolve, reject: reject }); - me.mediaPermissionTimeout_ = setTimeout(function() { - me.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); - }, 45000); + if (me.mediaPermissionUsesExchange_) { + me.mediaPermissionRemainingMs_ = PERMISSION_TIMEOUT_MS; + me.updatePermissionClock_(); + } else { + me.mediaPermissionTimeout_ = setTimeout(function() { + me.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); + }, BASE_PERMISSION_TIMEOUT_MS); + } // 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. @@ -883,6 +915,11 @@ AirConsole.prototype.cleanUpMediaPermission_ = function cleanUpMediaPermission_( this.mediaPermissionPending_ = false; this.mediaPermissionConstraints_ = undefined; this.mediaPermissionTimeout_ = undefined; + this.mediaPermissionTimerStartedAt_ = undefined; + this.mediaPermissionHandOffAccepted_ = false; + this.preferredAudioInputDeviceHandler_ = null; + this.mediaPermissionStreams_ = []; + this.mediaPermissionUsesExchange_ = false; this.cachedMediaError_ = null; mediaPermissionCallbacks_.delete(this); } @@ -899,6 +936,374 @@ AirConsole.prototype.rejectMediaPermission_ = function rejectMediaPermission_(er if (cb) { cb.reject(error); } } +/** + * Returns true while the screen is paused, as published in its presence (`paused = { state, reason }`). + * The screen only publishes `paused` once its pause state first changes, so a missing `paused`, or a missing screen + * entry, means "not paused". + * @private + * @return {boolean} + */ +AirConsole.prototype.isScreenPaused_ = function isScreenPaused_() { + const screen = this.devices && this.devices[AirConsole.SCREEN]; + return !!(screen && screen.paused && screen.paused.state); +}; + +/** + * Starts or stops the permission clock to match the screen's pause state, keeping the remaining time across a pause. + * Called when a request starts and whenever the screen's presence changes. Does nothing for a request without the + * exchange, whose timeout is the base one. + * @private + */ +AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() { + var me = this; + if (!me.mediaPermissionPending_ || !me.mediaPermissionUsesExchange_) { + return; + } + const running = me.mediaPermissionTimeout_ !== undefined; + if (me.isScreenPaused_()) { + if (running) { + clearTimeout(me.mediaPermissionTimeout_); + me.mediaPermissionTimeout_ = undefined; + me.mediaPermissionRemainingMs_ -= Date.now() - me.mediaPermissionTimerStartedAt_; + me.mediaPermissionTimerStartedAt_ = undefined; + } + } else if (!running) { + me.mediaPermissionTimerStartedAt_ = Date.now(); + me.mediaPermissionTimeout_ = setTimeout(function () { + me.expireMediaPermission_(); + }, Math.max(0, me.mediaPermissionRemainingMs_)); + } +}; + +/** + * Ends the pending request once the permission timeout expires: stops any stream the request holds, tells the + * platform, then rejects the game's promise. Sending before rejecting means the platform has handled the timeout + * before the game can retry. + * @private + */ +AirConsole.prototype.expireMediaPermission_ = function expireMediaPermission_() { + this.mediaPermissionTimeout_ = undefined; + this.stopMediaPermissionStreams_(); + this.sendEvent_('userMediaRequestFailed', { + reason: 'timeout', + error: AirConsole.USER_MEDIA_ERROR_TYPE.timeout, + }); + this.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); +}; + +/** + * Opens the stream on the permission hand-off, as before the preferred audio input device exchange existed. Used when + * the platform does not announce the exchange. + * @private + * @param {string} type - 'userMediaPermissionGranted' or 'promptUserMediaPermission' + */ +AirConsole.prototype.startBaseMediaPermissionRequest_ = function startBaseMediaPermissionRequest_(type) { + var me = this; + navigator.mediaDevices.getUserMedia(me.mediaPermissionConstraints_).then( + function success(stream) { + if (!me.mediaPermissionPending_) { + // Timeout fired while the browser permission dialog was still open; + // the outer Promise is already settled — stop the orphaned stream to release hardware. + stream.getTracks().forEach(function (t) { t.stop(); }); + return; + } + // 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 the event branch. + me.sendEvent_('userMediaPermissionGranted', { + constraints: me.mediaPermissionConstraints_, + }); + me.resolveMediaPermission_(stream); + }, + function failure(error) { + if (!me.mediaPermissionPending_) { + return; + } + // Native controller: platform already granted permission but stream open failed. + if (type === 'userMediaPermissionGranted') { + me.rejectMediaPermission_(error); + } else { + me.cachedMediaError_ = error; + me.sendEvent_('userMediaPermissionDenied'); + } + }); +}; + +/** + * Returns true if requestId is the request that is still pending. + * @private + * @param {number} requestId + * @return {boolean} + */ +AirConsole.prototype.isCurrentMediaPermission_ = function isCurrentMediaPermission_(requestId) { + return this.mediaPermissionPending_ && this.mediaPermissionRequestId_ === requestId; +}; + +/** + * Stops the tracks of every stream the pending request has opened and not handed to the game. + * @private + */ +AirConsole.prototype.stopMediaPermissionStreams_ = function stopMediaPermissionStreams_() { + (this.mediaPermissionStreams_ || []).forEach(function (stream) { + stream.getTracks().forEach(function (t) { t.stop(); }); + }); + this.mediaPermissionStreams_ = []; +}; + +/** + * Returns the name the platform receives for an error: the message of an AirConsoleUserMediaError, or the name of a + * browser DOMException. + * @private + * @param {*} error + * @return {string} + */ +AirConsole.getUserMediaErrorName_ = function getUserMediaErrorName_(error) { + if (error instanceof AirConsoleUserMediaError) { + return error.message; + } + return (error && error.name) || String(error); +}; + +/** + * Returns the device id the stream's audio track captures from, or '' when it has none. + * @private + * @param {MediaStream} stream + * @return {string} + */ +AirConsole.getActiveAudioInputDeviceId_ = function getActiveAudioInputDeviceId_(stream) { + const track = stream && stream.getAudioTracks && stream.getAudioTracks()[0]; + if (!track || track.readyState === 'ended' || !track.getSettings) { + return ''; + } + return track.getSettings().deviceId || ''; +}; + +/** + * Returns the audio inputs from enumerateDevices(), or an empty list if they cannot be enumerated. + * @private + * @return {Promise>} + */ +AirConsole.enumerateAudioInputDevices_ = function enumerateAudioInputDevices_() { + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) { + return Promise.resolve([]); + } + return navigator.mediaDevices.enumerateDevices().then(function (devices) { + return devices.filter(function (device) { + return device.kind === 'audioinput'; + }).map(function (device) { + return { deviceId: device.deviceId, label: device.label }; + }); + }, function () { + return []; + }); +}; + +/** + * Returns the game's constraints with deviceId merged into `audio` as a plain (non-exact) value, so the browser + * treats it as a preference and opens another microphone when that device is gone. + * @private + * @param {AirConsole~GetUserMediaConstraint} constraints + * @param {string|null} deviceId + * @return {Object} + */ +AirConsole.getConstraintsWithAudioInputDevice_ = function getConstraintsWithAudioInputDevice_(constraints, deviceId) { + if (!deviceId) { + return constraints; + } + const audio = typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {}; + audio.deviceId = deviceId; + return Object.assign({}, constraints, { audio: audio }); +}; + +/** + * Asks the platform which audio input the stream should use and waits for its preferredAudioInputDevice reply. + * The reply is accepted once per request, and only while it is awaited. + * @private + * @param {number} requestId + * @param {Object} payload - { devices, activeDeviceId } on a first request; empty on a later one. + * @param {function(string|null)} onReply + */ +AirConsole.prototype.requestPreferredAudioInputDevice_ = function requestPreferredAudioInputDevice_(requestId, + payload, onReply) { + var me = this; + if (!me.isCurrentMediaPermission_(requestId)) { + return; + } + me.preferredAudioInputDeviceHandler_ = function (deviceId) { + me.preferredAudioInputDeviceHandler_ = null; + me.audioInputExchangeCompleted_ = true; + onReply(deviceId || null); + }; + me.sendEvent_('requestPreferredAudioInputDevice', payload); +}; + +/** + * Hands a stream to the game and reports the devices and the device the stream actually captures from. + * @private + * @param {number} requestId + * @param {MediaStream} stream + */ +AirConsole.prototype.resolveMediaPermissionWithReport_ = function resolveMediaPermissionWithReport_(requestId, + stream) { + var me = this; + if (!me.isCurrentMediaPermission_(requestId)) { + stream.getTracks().forEach(function (t) { t.stop(); }); + return; + } + me.mediaPermissionStreams_ = me.mediaPermissionStreams_.filter(function (s) { return s !== stream; }); + // 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 the event branch. + me.sendEvent_('userMediaPermissionGranted', { + constraints: me.mediaPermissionConstraints_, + }); + me.resolveMediaPermission_(stream); + AirConsole.enumerateAudioInputDevices_().then(function (devices) { + me.sendEvent_('audioInputDevicesReported', { + devices: devices, + activeDeviceId: AirConsole.getActiveAudioInputDeviceId_(stream), + }); + }); +}; + +/** + * Ends the pending request after an open failed for a reason other than a permission denial: tells the platform, + * then lets the error reach the game. + * @private + * @param {number} requestId + * @param {*} error + */ +AirConsole.prototype.failMediaPermissionOpen_ = function failMediaPermissionOpen_(requestId, error) { + if (!this.isCurrentMediaPermission_(requestId)) { + return; + } + this.sendEvent_('userMediaRequestFailed', { + reason: 'open-error', + error: AirConsole.getUserMediaErrorName_(error), + }); + this.rejectMediaPermission_(error); +}; + +/** + * Ends the pending request after an open that followed stream #1 failed: a fresh open or stream #2 on a first + * request, or a later request's single open. On web a NotAllowedError is a permission denial and takes the base's + * denial path. Any other error, and every error in the app, where the native app has just granted the permission, + * reaches the game through failMediaPermissionOpen_. + * @private + * @param {number} requestId + * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). + * @param {*} error + */ +AirConsole.prototype.failMediaPermissionReopen_ = function failMediaPermissionReopen_(requestId, handOffType, + error) { + if (!this.isCurrentMediaPermission_(requestId)) { + return; + } + if (handOffType === 'promptUserMediaPermission' && error && error.name === 'NotAllowedError') { + this.cachedMediaError_ = error; + this.sendEvent_('userMediaPermissionDenied'); + } else { + this.failMediaPermissionOpen_(requestId, error); + } +}; + +/** + * Opens a stream for the pending request, holding it until it is handed to the game. + * @private + * @param {number} requestId + * @param {Object} constraints + * @return {Promise} null when the request ended while the browser was opening. + */ +AirConsole.prototype.openMediaPermissionStream_ = function openMediaPermissionStream_(requestId, constraints) { + var me = this; + return navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { + if (!me.isCurrentMediaPermission_(requestId)) { + // The request ended while the browser was opening; stop the orphaned stream to release hardware. + stream.getTracks().forEach(function (t) { t.stop(); }); + return null; + } + me.mediaPermissionStreams_.push(stream); + return stream; + }); +}; + +/** + * First request of a game: opens stream #1 with the game's constraints, asks for the preferred audio input device + * with the device list, then keeps stream #1, reopens a fresh stream if it ended, or switches to stream #2. + * @private + * @param {number} requestId + * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). + */ +AirConsole.prototype.startFirstMediaPermissionRequest_ = function startFirstMediaPermissionRequest_(requestId, + handOffType) { + var me = this; + const constraints = me.mediaPermissionConstraints_; + me.openMediaPermissionStream_(requestId, constraints).then(function (stream1) { + if (!stream1) { + return; + } + AirConsole.enumerateAudioInputDevices_().then(function (devices) { + me.requestPreferredAudioInputDevice_(requestId, { + devices: devices, + activeDeviceId: AirConsole.getActiveAudioInputDeviceId_(stream1), + }, function (deviceId) { + const track = stream1.getAudioTracks()[0]; + const live = !!track && track.readyState === 'live'; + if (live && (deviceId === null || deviceId === AirConsole.getActiveAudioInputDeviceId_(stream1))) { + me.resolveMediaPermissionWithReport_(requestId, stream1); + return; + } + // Stream #1 ended, or is on another device: iOS refuses a second audio input while the first is live, + // so stop it before opening the next one. + me.stopMediaPermissionStreams_(); + me.openMediaPermissionStream_(requestId, + AirConsole.getConstraintsWithAudioInputDevice_(constraints, deviceId)).then(function (stream) { + if (stream) { + me.resolveMediaPermissionWithReport_(requestId, stream); + } + }, function (error) { + me.failMediaPermissionReopen_(requestId, handOffType, error); + }); + }); + }); + }, function (error) { + if (!me.isCurrentMediaPermission_(requestId)) { + return; + } + if (handOffType === 'userMediaPermissionGranted') { + // Native controller: platform already granted permission but stream open failed. + me.failMediaPermissionOpen_(requestId, error); + } else { + me.cachedMediaError_ = error; + me.sendEvent_('userMediaPermissionDenied'); + } + }); +}; + +/** + * Later request in the same game: asks for the preferred audio input device before opening anything, then opens + * once with it merged in as a non-exact constraint. + * @private + * @param {number} requestId + * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). + */ +AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMediaPermissionRequest_(requestId, + handOffType) { + var me = this; + const constraints = me.mediaPermissionConstraints_; + me.requestPreferredAudioInputDevice_(requestId, {}, function (deviceId) { + me.openMediaPermissionStream_(requestId, + AirConsole.getConstraintsWithAudioInputDevice_(constraints, deviceId)).then(function (stream) { + if (stream) { + me.resolveMediaPermissionWithReport_(requestId, stream); + } + }, function (error) { + // On web a revoked permission is first seen here, as a NotAllowedError. + me.failMediaPermissionReopen_(requestId, handOffType, error); + }); + }); +}; + /** * Releases resources held by this AirConsole instance. * Call this when the instance is no longer needed — for example, when navigating @@ -908,7 +1313,12 @@ AirConsole.prototype.rejectMediaPermission_ = function rejectMediaPermission_(er */ AirConsole.prototype.destroy = function destroy() { window.removeEventListener('message', this.messageEventListener_); - if (this.mediaPermissionPending_) { + if (this.mediaPermissionPending_ && this.mediaPermissionUsesExchange_) { + // End the request like a timeout: stop any stream it holds (stream #1 while the reply is awaited), tell the + // platform so it closes its prompts, then reject. The timer is cleared first, so it cannot expire a second time. + clearTimeout(this.mediaPermissionTimeout_); + this.expireMediaPermission_(); + } else if (this.mediaPermissionPending_) { this.rejectMediaPermission_(new AirConsoleUserMediaError(AirConsole.USER_MEDIA_ERROR_TYPE.timeout)); } else if (this.mediaPermissionTimeout_) { clearTimeout(this.mediaPermissionTimeout_); @@ -1631,6 +2041,9 @@ AirConsole.prototype.onPostMessage_ = function(event) { var sender = data.device_id; me.devices[sender] = data.device_data; + if (sender === AirConsole.SCREEN) { + me.updatePermissionClock_(); + } me.onDeviceStateChange(sender, data.device_data); var is_connect = me.isLocationLoadedMessage_(game_url_before, game_url, game_url_after); var is_disconnect = me.isLocationUnloadedMessage_(game_url_before, game_url, game_url_after); @@ -1670,6 +2083,8 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === "ready") { me.device_id = data.device_id; me.devices = data.devices; + me.preferredAudioInputDeviceSupported_ = data.preferredAudioInputDeviceSupported === true; + me.updatePermissionClock_(); if (me.server_time_offset !== false) { me.server_time_offset = data.server_time_offset || 0; } @@ -1787,35 +2202,25 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.rejectMediaPermission_(error); } } else if (type === 'userMediaPermissionGranted' || type === 'promptUserMediaPermission') { - navigator.mediaDevices.getUserMedia(me.mediaPermissionConstraints_).then( - function success(stream) { - if (!me.mediaPermissionPending_) { - // Timeout fired while the browser permission dialog was still open; - // the outer Promise is already settled — stop the orphaned stream to release hardware. - stream.getTracks().forEach(function (t) { t.stop(); }); - return; - } - // 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. - me.sendEvent_('userMediaPermissionGranted', { - constraints: me.mediaPermissionConstraints_, - }); - me.resolveMediaPermission_(stream); - }, - function failure(error) { - if (!me.mediaPermissionPending_) { - return; - } - // Native controller: platform already granted permission but stream open failed. - if (type === 'userMediaPermissionGranted') { - me.rejectMediaPermission_(error); - } else { - me.cachedMediaError_ = error; - me.sendEvent_('userMediaPermissionDenied'); - } - } - ); + if (!me.mediaPermissionUsesExchange_) { + me.startBaseMediaPermissionRequest_(type); + return; + } + // The hand-off is accepted once per request, so a duplicate hand-off cannot open a second stream. + if (me.mediaPermissionHandOffAccepted_) { + return; + } + me.mediaPermissionHandOffAccepted_ = true; + if (me.audioInputExchangeCompleted_) { + me.startLaterMediaPermissionRequest_(me.mediaPermissionRequestId_, type); + } else { + me.startFirstMediaPermissionRequest_(me.mediaPermissionRequestId_, type); + } + } else if (type === 'preferredAudioInputDevice') { + // Accepted only while awaited; a reply meant for an earlier request is ignored. + if (me.preferredAudioInputDeviceHandler_) { + me.preferredAudioInputDeviceHandler_(data.data && data.data.deviceId); + } } } }; From 9d0179ae73b22031b10ada53aa1f63d378679ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:40:25 +0000 Subject: [PATCH 08/11] . B: Report version 1.12.0 from the 1.12.0 beta The beta kept me.version = "1.11.0", so the platform saw it as 1.11.0. Its test page reuses the 1.11.0 suite, whose setup test pinned that version; the page now tells the shared suite which version to expect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- beta/airconsole-1.12.0.js | 2 +- tests/airconsole-1.12.0-spec.html | 1 + tests/spec/airconsole-1.11.0-spec.js | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beta/airconsole-1.12.0.js b/beta/airconsole-1.12.0.js index d706e26..b877e3c 100644 --- a/beta/airconsole-1.12.0.js +++ b/beta/airconsole-1.12.0.js @@ -1932,7 +1932,7 @@ AirConsole.prototype.getDefaultPlayerSilencing_ = function() { AirConsole.prototype.init_ = function(opts) { opts = opts || {}; var me = this; - me.version = "1.11.0"; + me.version = "1.12.0"; me.devices = []; me.silencedUpdatesQueue_ = {}; me.server_time_offset = opts.synchronize_time ? 0 : false; diff --git a/tests/airconsole-1.12.0-spec.html b/tests/airconsole-1.12.0-spec.html index c4b77d4..c146c47 100644 --- a/tests/airconsole-1.12.0-spec.html +++ b/tests/airconsole-1.12.0-spec.html @@ -35,6 +35,7 @@ + diff --git a/tests/spec/airconsole-1.11.0-spec.js b/tests/spec/airconsole-1.11.0-spec.js index 5c2477f..71a5288 100644 --- a/tests/spec/airconsole-1.11.0-spec.js +++ b/tests/spec/airconsole-1.11.0-spec.js @@ -48,7 +48,8 @@ describe("AirConsole 1.11.0", function () { tearDown(); }); - testSetup("1.11.0"); + // A newer version's page that reuses this suite sets EXPECTED_API_VERSION. + testSetup(typeof EXPECTED_API_VERSION === "undefined" ? "1.11.0" : EXPECTED_API_VERSION); }); From 0ff05af69603c2c1d694a6b895c3a25993bd3e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:40:47 +0000 Subject: [PATCH 09/11] . B: Revert "Report version 1.12.0 from the 1.12.0 beta" This reverts commit 9d0179a. The 1.12.0 beta's version string stays as master has it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- beta/airconsole-1.12.0.js | 2 +- tests/airconsole-1.12.0-spec.html | 1 - tests/spec/airconsole-1.11.0-spec.js | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/beta/airconsole-1.12.0.js b/beta/airconsole-1.12.0.js index b877e3c..d706e26 100644 --- a/beta/airconsole-1.12.0.js +++ b/beta/airconsole-1.12.0.js @@ -1932,7 +1932,7 @@ AirConsole.prototype.getDefaultPlayerSilencing_ = function() { AirConsole.prototype.init_ = function(opts) { opts = opts || {}; var me = this; - me.version = "1.12.0"; + me.version = "1.11.0"; me.devices = []; me.silencedUpdatesQueue_ = {}; me.server_time_offset = opts.synchronize_time ? 0 : false; diff --git a/tests/airconsole-1.12.0-spec.html b/tests/airconsole-1.12.0-spec.html index c146c47..c4b77d4 100644 --- a/tests/airconsole-1.12.0-spec.html +++ b/tests/airconsole-1.12.0-spec.html @@ -35,7 +35,6 @@ - diff --git a/tests/spec/airconsole-1.11.0-spec.js b/tests/spec/airconsole-1.11.0-spec.js index 71a5288..5c2477f 100644 --- a/tests/spec/airconsole-1.11.0-spec.js +++ b/tests/spec/airconsole-1.11.0-spec.js @@ -48,8 +48,7 @@ describe("AirConsole 1.11.0", function () { tearDown(); }); - // A newer version's page that reuses this suite sets EXPECTED_API_VERSION. - testSetup(typeof EXPECTED_API_VERSION === "undefined" ? "1.11.0" : EXPECTED_API_VERSION); + testSetup("1.11.0"); }); From b0a1c7b0f9dfca9d54e6b2d1714ca495cb068a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:47:16 +0000 Subject: [PATCH 10/11] . r: Rename the ready flag to audioInputExchangeEnabled The flag switches on the audio input exchange flow; the platform decides when (a car screen), so the API needs no notion of cars. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- CHANGELOG.md | 2 +- airconsole-1.11.0.js | 4 ++-- beta/airconsole-1.12.0.js | 4 ++-- tests/spec/methods/spec-usermedia-permissions.js | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4286554..973fa68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ - The API reports the audio inputs and the active device after every successful open (`audioInputDevicesReported`), and failures the platform would not otherwise learn of (`userMediaRequestFailed`). - The preferred audio input device exchange runs only when the platform announces it in `ready` - (`preferredAudioInputDeviceSupported: true`); otherwise `getUserMedia` behaves as before. + (`audioInputExchangeEnabled: true`); otherwise `getUserMedia` behaves as before. ### Changed diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index 61e4416..8262a80 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -893,7 +893,7 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { me.preferredAudioInputDeviceHandler_ = null; me.mediaPermissionStreams_ = []; // Taken per request, so a later ready cannot switch flows while a request is pending. - me.mediaPermissionUsesExchange_ = me.preferredAudioInputDeviceSupported_ === true; + me.mediaPermissionUsesExchange_ = me.audioInputExchangeEnabled_ === true; mediaPermissionCallbacks_.set(me, { resolve: resolve, reject: reject }); if (me.mediaPermissionUsesExchange_) { me.mediaPermissionRemainingMs_ = PERMISSION_TIMEOUT_MS; @@ -2009,7 +2009,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === "ready") { me.device_id = data.device_id; me.devices = data.devices; - me.preferredAudioInputDeviceSupported_ = data.preferredAudioInputDeviceSupported === true; + me.audioInputExchangeEnabled_ = data.audioInputExchangeEnabled === true; me.updatePermissionClock_(); if (me.server_time_offset !== false) { me.server_time_offset = data.server_time_offset || 0; diff --git a/beta/airconsole-1.12.0.js b/beta/airconsole-1.12.0.js index d706e26..fccdf4b 100644 --- a/beta/airconsole-1.12.0.js +++ b/beta/airconsole-1.12.0.js @@ -893,7 +893,7 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { me.preferredAudioInputDeviceHandler_ = null; me.mediaPermissionStreams_ = []; // Taken per request, so a later ready cannot switch flows while a request is pending. - me.mediaPermissionUsesExchange_ = me.preferredAudioInputDeviceSupported_ === true; + me.mediaPermissionUsesExchange_ = me.audioInputExchangeEnabled_ === true; mediaPermissionCallbacks_.set(me, { resolve: resolve, reject: reject }); if (me.mediaPermissionUsesExchange_) { me.mediaPermissionRemainingMs_ = PERMISSION_TIMEOUT_MS; @@ -2083,7 +2083,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } else if (data.action === "ready") { me.device_id = data.device_id; me.devices = data.devices; - me.preferredAudioInputDeviceSupported_ = data.preferredAudioInputDeviceSupported === true; + me.audioInputExchangeEnabled_ = data.audioInputExchangeEnabled === true; me.updatePermissionClock_(); if (me.server_time_offset !== false) { me.server_time_offset = data.server_time_offset || 0; diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index 286d893..ff7beba 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -818,7 +818,7 @@ function testUserMediaPermissions() { beforeEach(function () { initAirConsoleAsController(); // The platform announced the exchange in ready (see "ready flag" below). - airconsole.preferredAudioInputDeviceSupported_ = true; + airconsole.audioInputExchangeEnabled_ = true; sent = []; reply = null; spyOn(airconsole, 'sendEvent_').and.callFake(function (type, data) { @@ -843,17 +843,17 @@ function testUserMediaPermissions() { it('Should leave the exchange off when ready does not announce it', function () { dispatchReady({}); - expect(airconsole.preferredAudioInputDeviceSupported_).toBe(false); + expect(airconsole.audioInputExchangeEnabled_).toBe(false); }); it('Should turn the exchange on when ready announces it', function () { - airconsole.preferredAudioInputDeviceSupported_ = false; - dispatchReady({ preferredAudioInputDeviceSupported: true }); - expect(airconsole.preferredAudioInputDeviceSupported_).toBe(true); + airconsole.audioInputExchangeEnabled_ = false; + dispatchReady({ audioInputExchangeEnabled: true }); + expect(airconsole.audioInputExchangeEnabled_).toBe(true); }); it('Should open on the hand-off and send none of the new events without the flag', async function () { - airconsole.preferredAudioInputDeviceSupported_ = false; + airconsole.audioInputExchangeEnabled_ = false; const stream = makeFakeStream('mic-car'); spyGetUserMediaSequence([Promise.resolve(stream)]); const result = airconsole.getUserMedia({ audio: true }); @@ -870,7 +870,7 @@ function testUserMediaPermissions() { it('Should keep the 45 s timeout running through a screen pause without the flag', async function () { jasmine.clock().install(); try { - airconsole.preferredAudioInputDeviceSupported_ = false; + airconsole.audioInputExchangeEnabled_ = false; const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); updateScreen({ paused: { state: true, reason: 'audio-focus-loss' } }); jasmine.clock().tick(45001); @@ -882,7 +882,7 @@ function testUserMediaPermissions() { }); it('Should reject on destroy() without userMediaRequestFailed when the flag is off', async function () { - airconsole.preferredAudioInputDeviceSupported_ = false; + airconsole.audioInputExchangeEnabled_ = false; const result = airconsole.getUserMedia({ audio: true }).catch(function (error) { return error; }); airconsole.destroy(); expect((await result).message).toBe(AirConsole.USER_MEDIA_ERROR_TYPE.timeout); From c23379a3d0bcf793c350a78336393987d1c38cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:08:58 +0000 Subject: [PATCH 11/11] . d: Correct the getUserMedia exchange comments Comments and CHANGELOG only: describe what each helper actually does (destroy() also ends a request, the reply guard, the first/later flow choice, the non-exact device) and drop stale timeout wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KB3fBY6FZ4i3utrTgAvy3a --- CHANGELOG.md | 9 ++--- airconsole-1.11.0.js | 69 +++++++++++++++++++++++---------------- beta/airconsole-1.12.0.js | 69 +++++++++++++++++++++++---------------- 3 files changed, 87 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 973fa68..ab28480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,11 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ - Added `AirConsole.requestPhotonEngineAuth()` and `onPhotonEngineAuth(ticket)` so screens can authenticate with Photon Custom Authentication. - `AirConsole.getUserMedia` asks the platform which audio input a stream should use before handing it to the game (`requestPreferredAudioInputDevice` / `preferredAudioInputDevice`), so car head units can keep games on the phone's - built-in microphone. The first request of a game opens once, then keeps that stream, reopens it, or switches to the - preferred device; later requests open once with the device merged in as a non-exact `deviceId`. -- The API reports the audio inputs and the active device after every successful open (`audioInputDevicesReported`), and - failures the platform would not otherwise learn of (`userMediaRequestFailed`). + built-in microphone. The first request opens a stream, then keeps it, opens a fresh one if it ended, or switches to + the preferred device; later requests ask first, then open once with the device merged in as a non-exact `deviceId`. +- The API reports the audio inputs and the active device each time `getUserMedia` resolves + (`audioInputDevicesReported`), and failures the platform would not otherwise learn of, a timeout or an open error + (`userMediaRequestFailed`). - The preferred audio input device exchange runs only when the platform announces it in `ready` (`audioInputExchangeEnabled: true`); otherwise `getUserMedia` behaves as before. diff --git a/airconsole-1.11.0.js b/airconsole-1.11.0.js index 8262a80..8f53efc 100644 --- a/airconsole-1.11.0.js +++ b/airconsole-1.11.0.js @@ -824,14 +824,15 @@ const BASE_PERMISSION_TIMEOUT_MS = 45000; /** * @typedef {Object} AirConsole~GetUserMediaConstraint - * @property {boolean} audio - Whether to request microphone permissions. Must be provided; video is not supported. + * @property {boolean|MediaTrackConstraints} audio - Whether to request microphone permissions, or the audio track + * constraints. Must be truthy; video is not supported. */ /** * Requests microphone permissions for the controller device. * Can only be called by a controller (not the screen). Video constraints are not supported. * - * @param {AirConsole~GetUserMediaConstraint} constraints - Media constraints. Must include `audio: true`. + * @param {AirConsole~GetUserMediaConstraint} constraints - Media constraints. Must include a truthy `audio`. * Video constraints are not supported and will cause the promise to reject with * {@link AirConsole.USER_MEDIA_ERROR_TYPE}.invalidConstraints. * @return {Promise} Resolves with the granted {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaStream MediaStream} @@ -843,10 +844,11 @@ const BASE_PERMISSION_TIMEOUT_MS = 45000; * Note: callers are responsible for stopping stream tracks when the stream is no longer needed: * `stream.getTracks().forEach(function(t) { t.stop(); })` * - * On a platform that announces it (a car head unit), the platform may prefer another audio input, e.g. the - * phone's built-in microphone; the API then resolves with a stream on that device. There a request that stays - * unsettled for 60 s of unpaused time rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock - * stops while the screen is paused. Everywhere else the timeout is 45 s. + * On a platform that enables the preferred audio input exchange (e.g. a car head unit), the platform may prefer + * another audio input, such as the phone's built-in microphone; the API then asks the browser for that device, + * which opens another microphone if it is unavailable. There a request that stays unsettled for 60 s of unpaused + * time rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock stops while the screen is paused. + * Everywhere else the timeout is 45 s. * * @example * airconsole.getUserMedia({ audio: true }).then(function(stream) { @@ -950,8 +952,8 @@ AirConsole.prototype.isScreenPaused_ = function isScreenPaused_() { /** * Starts or stops the permission clock to match the screen's pause state, keeping the remaining time across a pause. - * Called when a request starts and whenever the screen's presence changes. Does nothing for a request without the - * exchange, whose timeout is the base one. + * Called when a request starts, on `ready`, and whenever the screen's presence changes. Does nothing when no request + * is pending, or for a request without the exchange, whose timeout is the base one. * @private */ AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() { @@ -976,9 +978,9 @@ AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() }; /** - * Ends the pending request once the permission timeout expires: stops any stream the request holds, tells the - * platform, then rejects the game's promise. Sending before rejecting means the platform has handled the timeout - * before the game can retry. + * Ends the pending request when the permission timeout expires, or on destroy(): stops any stream the request holds, + * tells the platform, then rejects the game's promise. Sending before rejecting means the platform receives the + * failure before any retry the game sends. * @private */ AirConsole.prototype.expireMediaPermission_ = function expireMediaPermission_() { @@ -1002,7 +1004,7 @@ AirConsole.prototype.startBaseMediaPermissionRequest_ = function startBaseMediaP navigator.mediaDevices.getUserMedia(me.mediaPermissionConstraints_).then( function success(stream) { if (!me.mediaPermissionPending_) { - // Timeout fired while the browser permission dialog was still open; + // The request ended (timeout or destroy()) while the browser permission dialog was still open; // the outer Promise is already settled — stop the orphaned stream to release hardware. stream.getTracks().forEach(function (t) { t.stop(); }); return; @@ -1051,8 +1053,8 @@ AirConsole.prototype.stopMediaPermissionStreams_ = function stopMediaPermissionS }; /** - * Returns the name the platform receives for an error: the message of an AirConsoleUserMediaError, or the name of a - * browser DOMException. + * Returns the name the platform receives for an error: the message of an AirConsoleUserMediaError, otherwise the + * error's name (e.g. a DOMException's), or String(error) if it has none. * @private * @param {*} error * @return {string} @@ -1065,7 +1067,8 @@ AirConsole.getUserMediaErrorName_ = function getUserMediaErrorName_(error) { }; /** - * Returns the device id the stream's audio track captures from, or '' when it has none. + * Returns the device id the stream's audio track captures from, or '' when the stream has no live audio track or + * the id is unavailable. * @private * @param {MediaStream} stream * @return {string} @@ -1100,7 +1103,8 @@ AirConsole.enumerateAudioInputDevices_ = function enumerateAudioInputDevices_() /** * Returns the game's constraints with deviceId merged into `audio` as a plain (non-exact) value, so the browser - * treats it as a preference and opens another microphone when that device is gone. + * treats it as a preference and opens another microphone when that device is gone. Returns the constraints unchanged + * when deviceId is null. * @private * @param {AirConsole~GetUserMediaConstraint} constraints * @param {string|null} deviceId @@ -1117,7 +1121,8 @@ AirConsole.getConstraintsWithAudioInputDevice_ = function getConstraintsWithAudi /** * Asks the platform which audio input the stream should use and waits for its preferredAudioInputDevice reply. - * The reply is accepted once per request, and only while it is awaited. + * The reply is accepted once per request, and only while it is awaited. An accepted reply marks the exchange + * completed, so later requests use the later-request flow. * @private * @param {number} requestId * @param {Object} payload - { devices, activeDeviceId } on a first request; empty on a later one. @@ -1138,7 +1143,8 @@ AirConsole.prototype.requestPreferredAudioInputDevice_ = function requestPreferr }; /** - * Hands a stream to the game and reports the devices and the device the stream actually captures from. + * Sends userMediaPermissionGranted, hands the stream to the game, then reports the audio inputs and the device the + * stream actually captures from (audioInputDevicesReported). Stops the stream instead if the request has ended. * @private * @param {number} requestId * @param {MediaStream} stream @@ -1167,8 +1173,9 @@ AirConsole.prototype.resolveMediaPermissionWithReport_ = function resolveMediaPe }; /** - * Ends the pending request after an open failed for a reason other than a permission denial: tells the platform, - * then lets the error reach the game. + * Ends the pending request after an open failed in a way the denial path does not handle (any error after the + * native app's grant, a NotAllowedError included, or any other than NotAllowedError on web): sends + * userMediaRequestFailed with reason 'open-error', then rejects with the error. * @private * @param {number} requestId * @param {*} error @@ -1228,8 +1235,10 @@ AirConsole.prototype.openMediaPermissionStream_ = function openMediaPermissionSt }; /** - * First request of a game: opens stream #1 with the game's constraints, asks for the preferred audio input device - * with the device list, then keeps stream #1, reopens a fresh stream if it ended, or switches to stream #2. + * Used until the platform has answered once on this instance (a game's first request, or the next one after a + * request that ended before the reply): opens stream #1 with the game's constraints, asks for the preferred audio + * input device with the device list, then keeps stream #1, reopens a fresh stream if it ended, or switches to + * stream #2. * @private * @param {number} requestId * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). @@ -1281,8 +1290,8 @@ AirConsole.prototype.startFirstMediaPermissionRequest_ = function startFirstMedi }; /** - * Later request in the same game: asks for the preferred audio input device before opening anything, then opens - * once with it merged in as a non-exact constraint. + * Used once the platform has answered a previous request: asks for the preferred audio input device before opening + * anything, then opens once, with the preferred device merged in as a non-exact constraint when the reply names one. * @private * @param {number} requestId * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). @@ -1308,8 +1317,11 @@ AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMedi * Releases resources held by this AirConsole instance. * Call this when the instance is no longer needed — for example, when navigating * away in a single-page application — to remove the message event listener and - * cancel any pending getUserMedia timeout. Failing to call destroy() in such - * environments will leave a dangling listener and possibly a live timeout. + * end any pending getUserMedia request: it is rejected with + * {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout, and with the preferred audio input + * exchange its streams are stopped and userMediaRequestFailed is sent first. + * Failing to call destroy() in such environments will leave a dangling listener and + * possibly a live timeout. */ AirConsole.prototype.destroy = function destroy() { window.removeEventListener('message', this.messageEventListener_); @@ -2109,7 +2121,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { const { type } = data; // 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). + // (e.g. after the timeout has already rejected the pending Promise). if (!me.mediaPermissionPending_) { return; } @@ -2141,7 +2153,8 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.startFirstMediaPermissionRequest_(me.mediaPermissionRequestId_, type); } } else if (type === 'preferredAudioInputDevice') { - // Accepted only while awaited; a reply meant for an earlier request is ignored. + // Accepted only while awaited. A reply meant for an earlier request is ignored: the platform's messages arrive + // in order, so it arrives before this request's hand-off, while no reply is awaited. if (me.preferredAudioInputDeviceHandler_) { me.preferredAudioInputDeviceHandler_(data.data && data.data.deviceId); } diff --git a/beta/airconsole-1.12.0.js b/beta/airconsole-1.12.0.js index fccdf4b..f65af52 100644 --- a/beta/airconsole-1.12.0.js +++ b/beta/airconsole-1.12.0.js @@ -824,14 +824,15 @@ const BASE_PERMISSION_TIMEOUT_MS = 45000; /** * @typedef {Object} AirConsole~GetUserMediaConstraint - * @property {boolean} audio - Whether to request microphone permissions. Must be provided; video is not supported. + * @property {boolean|MediaTrackConstraints} audio - Whether to request microphone permissions, or the audio track + * constraints. Must be truthy; video is not supported. */ /** * Requests microphone permissions for the controller device. * Can only be called by a controller (not the screen). Video constraints are not supported. * - * @param {AirConsole~GetUserMediaConstraint} constraints - Media constraints. Must include `audio: true`. + * @param {AirConsole~GetUserMediaConstraint} constraints - Media constraints. Must include a truthy `audio`. * Video constraints are not supported and will cause the promise to reject with * {@link AirConsole.USER_MEDIA_ERROR_TYPE}.invalidConstraints. * @return {Promise} Resolves with the granted {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaStream MediaStream} @@ -843,10 +844,11 @@ const BASE_PERMISSION_TIMEOUT_MS = 45000; * Note: callers are responsible for stopping stream tracks when the stream is no longer needed: * `stream.getTracks().forEach(function(t) { t.stop(); })` * - * On a platform that announces it (a car head unit), the platform may prefer another audio input, e.g. the - * phone's built-in microphone; the API then resolves with a stream on that device. There a request that stays - * unsettled for 60 s of unpaused time rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock - * stops while the screen is paused. Everywhere else the timeout is 45 s. + * On a platform that enables the preferred audio input exchange (e.g. a car head unit), the platform may prefer + * another audio input, such as the phone's built-in microphone; the API then asks the browser for that device, + * which opens another microphone if it is unavailable. There a request that stays unsettled for 60 s of unpaused + * time rejects with {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout; the clock stops while the screen is paused. + * Everywhere else the timeout is 45 s. * * @example * airconsole.getUserMedia({ audio: true }).then(function(stream) { @@ -950,8 +952,8 @@ AirConsole.prototype.isScreenPaused_ = function isScreenPaused_() { /** * Starts or stops the permission clock to match the screen's pause state, keeping the remaining time across a pause. - * Called when a request starts and whenever the screen's presence changes. Does nothing for a request without the - * exchange, whose timeout is the base one. + * Called when a request starts, on `ready`, and whenever the screen's presence changes. Does nothing when no request + * is pending, or for a request without the exchange, whose timeout is the base one. * @private */ AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() { @@ -976,9 +978,9 @@ AirConsole.prototype.updatePermissionClock_ = function updatePermissionClock_() }; /** - * Ends the pending request once the permission timeout expires: stops any stream the request holds, tells the - * platform, then rejects the game's promise. Sending before rejecting means the platform has handled the timeout - * before the game can retry. + * Ends the pending request when the permission timeout expires, or on destroy(): stops any stream the request holds, + * tells the platform, then rejects the game's promise. Sending before rejecting means the platform receives the + * failure before any retry the game sends. * @private */ AirConsole.prototype.expireMediaPermission_ = function expireMediaPermission_() { @@ -1002,7 +1004,7 @@ AirConsole.prototype.startBaseMediaPermissionRequest_ = function startBaseMediaP navigator.mediaDevices.getUserMedia(me.mediaPermissionConstraints_).then( function success(stream) { if (!me.mediaPermissionPending_) { - // Timeout fired while the browser permission dialog was still open; + // The request ended (timeout or destroy()) while the browser permission dialog was still open; // the outer Promise is already settled — stop the orphaned stream to release hardware. stream.getTracks().forEach(function (t) { t.stop(); }); return; @@ -1051,8 +1053,8 @@ AirConsole.prototype.stopMediaPermissionStreams_ = function stopMediaPermissionS }; /** - * Returns the name the platform receives for an error: the message of an AirConsoleUserMediaError, or the name of a - * browser DOMException. + * Returns the name the platform receives for an error: the message of an AirConsoleUserMediaError, otherwise the + * error's name (e.g. a DOMException's), or String(error) if it has none. * @private * @param {*} error * @return {string} @@ -1065,7 +1067,8 @@ AirConsole.getUserMediaErrorName_ = function getUserMediaErrorName_(error) { }; /** - * Returns the device id the stream's audio track captures from, or '' when it has none. + * Returns the device id the stream's audio track captures from, or '' when the stream has no live audio track or + * the id is unavailable. * @private * @param {MediaStream} stream * @return {string} @@ -1100,7 +1103,8 @@ AirConsole.enumerateAudioInputDevices_ = function enumerateAudioInputDevices_() /** * Returns the game's constraints with deviceId merged into `audio` as a plain (non-exact) value, so the browser - * treats it as a preference and opens another microphone when that device is gone. + * treats it as a preference and opens another microphone when that device is gone. Returns the constraints unchanged + * when deviceId is null. * @private * @param {AirConsole~GetUserMediaConstraint} constraints * @param {string|null} deviceId @@ -1117,7 +1121,8 @@ AirConsole.getConstraintsWithAudioInputDevice_ = function getConstraintsWithAudi /** * Asks the platform which audio input the stream should use and waits for its preferredAudioInputDevice reply. - * The reply is accepted once per request, and only while it is awaited. + * The reply is accepted once per request, and only while it is awaited. An accepted reply marks the exchange + * completed, so later requests use the later-request flow. * @private * @param {number} requestId * @param {Object} payload - { devices, activeDeviceId } on a first request; empty on a later one. @@ -1138,7 +1143,8 @@ AirConsole.prototype.requestPreferredAudioInputDevice_ = function requestPreferr }; /** - * Hands a stream to the game and reports the devices and the device the stream actually captures from. + * Sends userMediaPermissionGranted, hands the stream to the game, then reports the audio inputs and the device the + * stream actually captures from (audioInputDevicesReported). Stops the stream instead if the request has ended. * @private * @param {number} requestId * @param {MediaStream} stream @@ -1167,8 +1173,9 @@ AirConsole.prototype.resolveMediaPermissionWithReport_ = function resolveMediaPe }; /** - * Ends the pending request after an open failed for a reason other than a permission denial: tells the platform, - * then lets the error reach the game. + * Ends the pending request after an open failed in a way the denial path does not handle (any error after the + * native app's grant, a NotAllowedError included, or any other than NotAllowedError on web): sends + * userMediaRequestFailed with reason 'open-error', then rejects with the error. * @private * @param {number} requestId * @param {*} error @@ -1228,8 +1235,10 @@ AirConsole.prototype.openMediaPermissionStream_ = function openMediaPermissionSt }; /** - * First request of a game: opens stream #1 with the game's constraints, asks for the preferred audio input device - * with the device list, then keeps stream #1, reopens a fresh stream if it ended, or switches to stream #2. + * Used until the platform has answered once on this instance (a game's first request, or the next one after a + * request that ended before the reply): opens stream #1 with the game's constraints, asks for the preferred audio + * input device with the device list, then keeps stream #1, reopens a fresh stream if it ended, or switches to + * stream #2. * @private * @param {number} requestId * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). @@ -1281,8 +1290,8 @@ AirConsole.prototype.startFirstMediaPermissionRequest_ = function startFirstMedi }; /** - * Later request in the same game: asks for the preferred audio input device before opening anything, then opens - * once with it merged in as a non-exact constraint. + * Used once the platform has answered a previous request: asks for the preferred audio input device before opening + * anything, then opens once, with the preferred device merged in as a non-exact constraint when the reply names one. * @private * @param {number} requestId * @param {string} handOffType - 'promptUserMediaPermission' (web) or 'userMediaPermissionGranted' (native app). @@ -1308,8 +1317,11 @@ AirConsole.prototype.startLaterMediaPermissionRequest_ = function startLaterMedi * Releases resources held by this AirConsole instance. * Call this when the instance is no longer needed — for example, when navigating * away in a single-page application — to remove the message event listener and - * cancel any pending getUserMedia timeout. Failing to call destroy() in such - * environments will leave a dangling listener and possibly a live timeout. + * end any pending getUserMedia request: it is rejected with + * {@link AirConsole.USER_MEDIA_ERROR_TYPE}.timeout, and with the preferred audio input + * exchange its streams are stopped and userMediaRequestFailed is sent first. + * Failing to call destroy() in such environments will leave a dangling listener and + * possibly a live timeout. */ AirConsole.prototype.destroy = function destroy() { window.removeEventListener('message', this.messageEventListener_); @@ -2185,7 +2197,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { const { type } = data; // 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). + // (e.g. after the timeout has already rejected the pending Promise). if (!me.mediaPermissionPending_) { return; } @@ -2217,7 +2229,8 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.startFirstMediaPermissionRequest_(me.mediaPermissionRequestId_, type); } } else if (type === 'preferredAudioInputDevice') { - // Accepted only while awaited; a reply meant for an earlier request is ignored. + // Accepted only while awaited. A reply meant for an earlier request is ignored: the platform's messages arrive + // in order, so it arrives before this request's hand-off, while no reply is awaited. if (me.preferredAudioInputDeviceHandler_) { me.preferredAudioInputDeviceHandler_(data.data && data.data.deviceId); }