From 87e9428c00a63860507903e34ac6431fdc940ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:06:49 +0100 Subject: [PATCH 01/11] ! f: Integrate requestMediaPermission Signed-off-by: marc-n-dream --- beta/airconsole-1.11.0.js | 116 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index efd178c..bdc6529 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -691,6 +691,77 @@ AirConsole.prototype.vibrate = function(options) { this.set_("vibrate", options); }; + +/** ------------------------------------------------------------------------ * + * @chapter MICROPHONE PERMISSION * + * ------------------------------------------------------------------------- */ + +/** + * Gets called on the game screen when a controller is granted microphone + * access as a result of calling requestMicrophoneAccess on that controller. + * @abstract + * @param {number} device_id - The device_id of the controller that was granted access. + */ +AirConsole.prototype.onMicrophoneAccessGranted = function(device_id) {}; + +/** + * Gets called on the game screen when microphone access is denied or + * subsequently lost for a controller that called requestMicrophoneAccess. + * @abstract + * @param {number} device_id - The device_id of the controller. + * @param {AirConsole~MicDenialReason} reason - The reason for denial or loss. + */ +AirConsole.prototype.onMicrophoneAccessDenied = function(device_id, reason) {}; + + +/** + * Requests media permissions (e.g. microphone) for the controller. + * Can only be called by a controller (not the screen). + * @param {Array.} mediaTypes - Array of media types to request. + * Currently only ["microphone"] is supported. + * @return {Promise.<{success: boolean, stream: MediaStream=, error: Error=}>} + */ +AirConsole.prototype.requestMediaPermissions = function requestMediaPermissions(mediaTypes) { + var me = this; + return new Promise(function(resolve) { + if (me.device_id === AirConsole.SCREEN) { + resolve({ success: false, error: new Error('requestMediaPermissions is not supported on screen') }); + return; + } + if (me.device_id === undefined || me.device_id === null) { + resolve({ success: false, error: new Error('AirConsole not ready') }); + return; + } + if (me.media_permission_pending_) { + resolve({ success: false, error: new Error('Request already in progress') }); + return; + } + if (!mediaTypes || mediaTypes.indexOf('microphone') === -1) { + resolve({ success: false, error: new Error('unsupported media type') }); + return; + } + me.media_permission_pending_ = true; + me.media_permission_resolve_ = resolve; + me.media_permission_timeout_ = setTimeout(function() { + me._resolveMediaPermission_({ success: false, error: new Error('timeout') }); + }, 30000); + + // Currently the media type is always 'microphone', but we send the requested types for future extensibility. + me.set_('operation', { name: 'request-microphone-permission', data: { mediaTypes: ['microphone'] } }); + }); +}; + +AirConsole.prototype._resolveMediaPermission_ = function(result) { + clearTimeout(this.media_permission_timeout_); + this.media_permission_pending_ = false; + this.media_permission_timeout_ = undefined; + var resolve = this.media_permission_resolve_; + this.media_permission_resolve_ = undefined; + if (resolve) { + resolve(result); + } +}; + /** ------------------------------------------------------------------------ * * @chapter ADS * * ------------------------------------------------------------------------- */ @@ -1453,6 +1524,51 @@ AirConsole.prototype.onPostMessage_ = function(event) { } } else if (data.action === 'setGameSafeArea') { me.onSetSafeArea(data.gameSafeArea); + } else if (data.action === 'operation') { + const opName = data.name; + if (opName === 'microphone-permission-denied') { + me._resolveMediaPermission_({success: false, error: new Error('Permission denied')}); + } else if (opName === 'microphone-permission-granted' || opName === 'microphone-permission-undefined') { + const userPromptStartTime = performance.now(); + navigator.mediaDevices.getUserMedia({audio: true, video: false}).then( + function success(stream) { + if (!stream.getAudioTracks().length) { + me._resolveMediaPermission_({success: false, error: new Error('No audio tracks')}); + } else { + me._resolveMediaPermission_({success: true, stream: stream}); + } + }, + function failure(err) { + if (opName === 'microphone-permission-granted') { + me._resolveMediaPermission_({success: false, error: err}); + } else if (opName === 'microphone-permission-undefined') { + const userPromptDuration = performance.now() - userPromptStartTime; + if (err.name === 'NotAllowedError') { + if (userPromptDuration < 300) { + me.set_('operation', {name: 'microphone-permission-user-hard-denied', data: {}}); + } else { + me.set_('operation', {name: 'microphone-permission-user-soft-denied', data: {}}); + } + } + + // TODO(ENG-2540): Why not this approach Dragan? Is this simply due to the perceived unreliability of the + // permissions API on Safari or is there a technical reason that this approach would not work? + // if (navigator.permissions && navigator.permissions.query) { + // navigator.permissions.query({name: 'microphone'}).then(function(status) { + // const opDenial = status.state === 'denied' + // ? 'microphone-permission-user-hard-denied' + // : 'microphone-permission-user-soft-denied'; + // me.set_('operation', {name: opDenial, data: {}}); + // }, function() { + // me.set_('operation', {name: 'microphone-permission-user-soft-denied', data: {}}); + // }); + // } else { + // me.set_('operation', {name: 'microphone-permission-user-soft-denied', data: {}}); + // } + } + } + ); + } } }; From 32fa2808cd284517ee6568fbb53f2e72079e23be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:22:40 +0100 Subject: [PATCH 02/11] T: Implement test coverage for media permission api #ENG-2540 --- tests/airconsole-1.11.0-spec.html | 1 + tests/spec/airconsole-1.11.0-spec.js | 13 + tests/spec/methods/spec-media-permissions.js | 255 +++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 tests/spec/methods/spec-media-permissions.js diff --git a/tests/airconsole-1.11.0-spec.html b/tests/airconsole-1.11.0-spec.html index a51dd6a..8a96cad 100644 --- a/tests/airconsole-1.11.0-spec.html +++ b/tests/airconsole-1.11.0-spec.html @@ -29,6 +29,7 @@ + diff --git a/tests/spec/airconsole-1.11.0-spec.js b/tests/spec/airconsole-1.11.0-spec.js index f40975e..1b0533e 100644 --- a/tests/spec/airconsole-1.11.0-spec.js +++ b/tests/spec/airconsole-1.11.0-spec.js @@ -384,4 +384,17 @@ describe("AirConsole 1.11.0", function () { testAirConsole110Plus(); }); + + /** + ====================================================================================== + TEST MEDIA PERMISSIONS FUNCTIONALITY + */ + + describe("Media Permissions", function () { + afterEach(function () { + tearDown(); + }); + + testMediaPermissions(); + }); }); diff --git a/tests/spec/methods/spec-media-permissions.js b/tests/spec/methods/spec-media-permissions.js new file mode 100644 index 0000000..f03cfb6 --- /dev/null +++ b/tests/spec/methods/spec-media-permissions.js @@ -0,0 +1,255 @@ +function testMediaPermissions() { + function initAirConsoleAsController() { + spyOn(document, 'getElementsByTagName').and.callFake(function() { + return [{ src: 'http://localhost/api/airconsole-latest.js' }]; + }); + airconsole = new AirConsole({ setup_document: false }); + airconsole.device_id = DEVICE_ID; // 2 = controller + airconsole.devices[0] = {}; + airconsole.devices[DEVICE_ID] = { uid: 1237, nickname: 'Sergio', location: LOCATION, custom: {} }; + } + + // Group 1: Early rejections (sync, resolve immediately) + + it('Should reject with "requestMediaPermissions is not supported on screen" when device_id === AirConsole.SCREEN', function(done) { + initAirConsoleAsController(); + airconsole.device_id = AirConsole.SCREEN; + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('requestMediaPermissions is not supported on screen'); + done(); + }); + }); + + it('Should reject with "AirConsole not ready" when device_id === undefined', function(done) { + initAirConsoleAsController(); + airconsole.device_id = undefined; + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('AirConsole not ready'); + done(); + }); + }); + + it('Should reject with "AirConsole not ready" when device_id === null', function(done) { + initAirConsoleAsController(); + airconsole.device_id = null; + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('AirConsole not ready'); + done(); + }); + }); + + it('Should reject with "Request already in progress" when media_permission_pending_ is already true', function(done) { + initAirConsoleAsController(); + airconsole.media_permission_pending_ = true; + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('Request already in progress'); + done(); + }); + }); + + it('Should reject with "unsupported media type" when mediaTypes is null', function(done) { + initAirConsoleAsController(); + + airconsole.requestMediaPermissions(null).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('unsupported media type'); + done(); + }); + }); + + it('Should reject with "unsupported media type" when mediaTypes is undefined', function(done) { + initAirConsoleAsController(); + + airconsole.requestMediaPermissions(undefined).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('unsupported media type'); + done(); + }); + }); + + it('Should reject with "unsupported media type" when mediaTypes is an empty array', function(done) { + initAirConsoleAsController(); + + airconsole.requestMediaPermissions([]).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('unsupported media type'); + done(); + }); + }); + + it('Should reject with "unsupported media type" when mediaTypes contains only non-microphone types', function(done) { + initAirConsoleAsController(); + + airconsole.requestMediaPermissions(['camera']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('unsupported media type'); + done(); + }); + }); + + it('Should set media_permission_pending_ to true when a valid request is started', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + airconsole.requestMediaPermissions(['microphone']); + + expect(airconsole.media_permission_pending_).toBe(true); + done(); + }); + + it('Should call set_("operation", ...) with correct payload when valid', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + airconsole.requestMediaPermissions(['microphone']); + + expect(airconsole.set_).toHaveBeenCalledWith('operation', jasmine.objectContaining({ + name: 'request-microphone-permission', + data: jasmine.objectContaining({ mediaTypes: ['microphone'] }) + })); + done(); + }); + + // Group 2: _resolveMediaPermission_ / operation handler responses + + it('Should resolve with {success: false, error} on microphone-permission-denied operation', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('Permission denied'); + done(); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-denied' }); + }); + + it('Should resolve with {success: true, stream: } on microphone-permission-granted when getUserMedia succeeds with audio tracks', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + var fakeStream = { getAudioTracks: function() { return [{}]; } }; + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(true); + expect(result.stream).toBe(fakeStream); + done(); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + }); + + it('Should resolve with {success: false, error} on microphone-permission-granted when getUserMedia resolves but stream has no audio tracks', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + var fakeStream = { getAudioTracks: function() { return []; } }; + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('No audio tracks'); + done(); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + }); + + it('Should resolve with {success: false, error: err} on microphone-permission-granted when getUserMedia rejects', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + var testError = new Error('getUserMedia error'); + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.reject(testError)); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error).toBe(testError); + done(); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + }); + + it('Should resolve with {success: true, stream: } on microphone-permission-undefined when getUserMedia succeeds with audio tracks', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + var fakeStream = { getAudioTracks: function() { return [{}]; } }; + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(true); + expect(result.stream).toBe(fakeStream); + done(); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-undefined' }); + }); + + it('Should clear media_permission_pending_ after resolution', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + var fakeStream = { getAudioTracks: function() { return [{}]; } }; + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(airconsole.media_permission_pending_).toBe(false); + done(); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + }); + + it('Should handle double-call to _resolveMediaPermission_ safely (second call is no-op)', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + + var resolveCallCount = 0; + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + resolveCallCount++; + expect(resolveCallCount).toBe(1); + expect(result.success).toBe(false); + expect(result.error.message).toBe('Permission denied'); + + // Now call _resolveMediaPermission_ again - this should be no-op + airconsole._resolveMediaPermission_({ success: true }); + + // After a small delay, verify resolveCallCount is still 1 + setTimeout(function() { + expect(resolveCallCount).toBe(1); + done(); + }, 50); + }); + + dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-denied' }); + }); + + // Group 3: timeout + + it('Should resolve with {success: false, error: {message: "timeout"}} after 30000ms', function(done) { + initAirConsoleAsController(); + spyOn(airconsole, 'set_'); + jasmine.clock().install(); + + airconsole.requestMediaPermissions(['microphone']).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('timeout'); + jasmine.clock().uninstall(); + done(); + }); + + jasmine.clock().tick(30001); + }); +} From 61825272bf115195c007984734f60089053b936b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:39:23 +0100 Subject: [PATCH 03/11] r: Update requestMediaPermissions -> getUserMedia r: restructure the event flow to use a distinct flow --- beta/airconsole-1.11.0.js | 90 +++++++------ tests/spec/methods/spec-media-permissions.js | 135 ++++++++----------- 2 files changed, 102 insertions(+), 123 deletions(-) diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index bdc6529..d5582a0 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -713,31 +713,37 @@ AirConsole.prototype.onMicrophoneAccessGranted = function(device_id) {}; */ AirConsole.prototype.onMicrophoneAccessDenied = function(device_id, reason) {}; +/** + * @typedef {Object} AirConsole~GetUserMediaConstraint + * @property {boolean} audio - Whether to request audio permissions. + * @property {boolean | object} video - True, to use default camera video stream or specific object following the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia getUserMedia constraints}. + */ /** * Requests media permissions (e.g. microphone) for the controller. * Can only be called by a controller (not the screen). - * @param {Array.} mediaTypes - Array of media types to request. - * Currently only ["microphone"] is supported. + * @param {Object.} constraints - User Media Request constraints + * Currently only 'audio' is supported. * @return {Promise.<{success: boolean, stream: MediaStream=, error: Error=}>} */ -AirConsole.prototype.requestMediaPermissions = function requestMediaPermissions(mediaTypes) { +AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { var me = this; return new Promise(function(resolve) { if (me.device_id === AirConsole.SCREEN) { - resolve({ success: false, error: new Error('requestMediaPermissions is not supported on screen') }); + resolve({ success: false, error: new Error('getUserMedia failed: getUserMedia is not supported on screen') }); return; } if (me.device_id === undefined || me.device_id === null) { - resolve({ success: false, error: new Error('AirConsole not ready') }); + resolve({ success: false, error: new Error('getUserMedia failed: AirConsole not ready') }); return; } if (me.media_permission_pending_) { - resolve({ success: false, error: new Error('Request already in progress') }); + resolve({ success: false, error: new Error('getUserMedia failed: Request already in progress') }); return; } - if (!mediaTypes || mediaTypes.indexOf('microphone') === -1) { - resolve({ success: false, error: new Error('unsupported media type') }); + if (!constraints || !(constraints.hasOwnProperty('audio') || constraints.hasOwnProperty('video'))) { + resolve({ success: false, error: new Error('getUserMedia failed: audio or video constraint must be specified') }); return; } me.media_permission_pending_ = true; @@ -746,16 +752,17 @@ AirConsole.prototype.requestMediaPermissions = function requestMediaPermissions( me._resolveMediaPermission_({ success: false, error: new Error('timeout') }); }, 30000); - // Currently the media type is always 'microphone', but we send the requested types for future extensibility. - me.set_('operation', { name: 'request-microphone-permission', data: { mediaTypes: ['microphone'] } }); + // Send the request to the platform to decide where and how the user media request needs to take place based on + // browser or controller environment. + me.sendEvent_('request-media-permission', { constraints: constraints }); }); }; -AirConsole.prototype._resolveMediaPermission_ = function(result) { +AirConsole.prototype._resolveMediaPermission_ = function _resolveMediaPermission_(result) { clearTimeout(this.media_permission_timeout_); this.media_permission_pending_ = false; this.media_permission_timeout_ = undefined; - var resolve = this.media_permission_resolve_; + const resolve = this.media_permission_resolve_; this.media_permission_resolve_ = undefined; if (resolve) { resolve(result); @@ -1426,6 +1433,13 @@ AirConsole.prototype.onPostMessage_ = function(event) { if (data.device_data._is_profile_update) { me.onDeviceProfileChange(sender); } + if (data.device_data._is_multimedia_update) { + if (data.device_data.microphone && data.device_data.microphone.granted === true) { + me.onMicrophoneAccessGranted(sender); + } else if (data.device_data.microphone && data.device_data.microphone.granted === false) { + me.onMicrophoneAccessDenied(sender, data.device_data.microphone.reason); + } + } } } } else if (data.action === "ready") { @@ -1524,47 +1538,31 @@ AirConsole.prototype.onPostMessage_ = function(event) { } } else if (data.action === 'setGameSafeArea') { me.onSetSafeArea(data.gameSafeArea); - } else if (data.action === 'operation') { - const opName = data.name; - if (opName === 'microphone-permission-denied') { - me._resolveMediaPermission_({success: false, error: new Error('Permission denied')}); - } else if (opName === 'microphone-permission-granted' || opName === 'microphone-permission-undefined') { + } else if (data.action === 'event') { + const { type } = data; + if (type === 'microphone-permission-denied') { + me._resolveMediaPermission_({success: false, error: new Error('getUserMedia failed: Permission denied')}); + } else if (type === 'microphone-permission-granted' || type === 'microphone-permission-undefined') { const userPromptStartTime = performance.now(); navigator.mediaDevices.getUserMedia({audio: true, video: false}).then( function success(stream) { - if (!stream.getAudioTracks().length) { - me._resolveMediaPermission_({success: false, error: new Error('No audio tracks')}); - } else { - me._resolveMediaPermission_({success: true, stream: stream}); - } + me._resolveMediaPermission_({success: true, stream: stream}); + me.sendEvent_('microphone-permission-granted', {}); }, function failure(err) { - if (opName === 'microphone-permission-granted') { + // Native controller + if (type === 'microphone-permission-granted') { me._resolveMediaPermission_({success: false, error: err}); - } else if (opName === 'microphone-permission-undefined') { + } else if (type === 'microphone-permission-undefined') { + // web based controller const userPromptDuration = performance.now() - userPromptStartTime; if (err.name === 'NotAllowedError') { if (userPromptDuration < 300) { - me.set_('operation', {name: 'microphone-permission-user-hard-denied', data: {}}); + me.sendEvent_('microphone-permission-user-hard-denied', {}); } else { - me.set_('operation', {name: 'microphone-permission-user-soft-denied', data: {}}); + me.sendEvent_('microphone-permission-user-soft-denied', {}); } } - - // TODO(ENG-2540): Why not this approach Dragan? Is this simply due to the perceived unreliability of the - // permissions API on Safari or is there a technical reason that this approach would not work? - // if (navigator.permissions && navigator.permissions.query) { - // navigator.permissions.query({name: 'microphone'}).then(function(status) { - // const opDenial = status.state === 'denied' - // ? 'microphone-permission-user-hard-denied' - // : 'microphone-permission-user-soft-denied'; - // me.set_('operation', {name: opDenial, data: {}}); - // }, function() { - // me.set_('operation', {name: 'microphone-permission-user-soft-denied', data: {}}); - // }); - // } else { - // me.set_('operation', {name: 'microphone-permission-user-soft-denied', data: {}}); - // } } } ); @@ -1642,6 +1640,16 @@ AirConsole.prototype.set_ = function(key, value) { AirConsole.postMessage_({ action: "set", key: key, value: value }); }; +/** + * Sends an event to the external AirConsole framework. + * @param {string} eventType - The type of the event. + * @param {serializable} eventData - The data of the event. Must be serializable. + * @private + */ +AirConsole.prototype.sendEvent_ = (eventType, eventData) => { + AirConsole.postMessage_({ action: 'event', type: eventType, data: eventData }); +}; + /** * Adds default css rules to documents so nothing is selectable, zoom is * fixed to 1 and preventing scrolling down (iOS 8 clients drop out of diff --git a/tests/spec/methods/spec-media-permissions.js b/tests/spec/methods/spec-media-permissions.js index f03cfb6..250de18 100644 --- a/tests/spec/methods/spec-media-permissions.js +++ b/tests/spec/methods/spec-media-permissions.js @@ -6,18 +6,18 @@ function testMediaPermissions() { airconsole = new AirConsole({ setup_document: false }); airconsole.device_id = DEVICE_ID; // 2 = controller airconsole.devices[0] = {}; - airconsole.devices[DEVICE_ID] = { uid: 1237, nickname: 'Sergio', location: LOCATION, custom: {} }; + airconsole.devices[DEVICE_ID] = { uid: 1237, nicktype: 'Sergio', location: LOCATION, custom: {} }; } // Group 1: Early rejections (sync, resolve immediately) - it('Should reject with "requestMediaPermissions is not supported on screen" when device_id === AirConsole.SCREEN', function(done) { + it('Should reject with "getUserMedia is not supported on screen" when device_id === AirConsole.SCREEN', function(done) { initAirConsoleAsController(); airconsole.device_id = AirConsole.SCREEN; - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('requestMediaPermissions is not supported on screen'); + expect(result.error.message).toBe('getUserMedia failed: getUserMedia is not supported on screen'); done(); }); }); @@ -26,9 +26,9 @@ function testMediaPermissions() { initAirConsoleAsController(); airconsole.device_id = undefined; - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('AirConsole not ready'); + expect(result.error.message).toBe('getUserMedia failed: AirConsole not ready'); done(); }); }); @@ -37,9 +37,9 @@ function testMediaPermissions() { initAirConsoleAsController(); airconsole.device_id = null; - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('AirConsole not ready'); + expect(result.error.message).toBe('getUserMedia failed: AirConsole not ready'); done(); }); }); @@ -48,180 +48,151 @@ function testMediaPermissions() { initAirConsoleAsController(); airconsole.media_permission_pending_ = true; - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('Request already in progress'); + expect(result.error.message).toBe('getUserMedia failed: Request already in progress'); done(); }); }); - it('Should reject with "unsupported media type" when mediaTypes is null', function(done) { + it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are null', function(done) { initAirConsoleAsController(); - airconsole.requestMediaPermissions(null).then(function(result) { + airconsole.getUserMedia(null).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('unsupported media type'); + expect(result.error.message).toBe('getUserMedia failed: audio or video constraint must be specified'); done(); }); }); - it('Should reject with "unsupported media type" when mediaTypes is undefined', function(done) { + it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are undefined', function(done) { initAirConsoleAsController(); - airconsole.requestMediaPermissions(undefined).then(function(result) { + airconsole.getUserMedia(undefined).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('unsupported media type'); + expect(result.error.message).toBe('getUserMedia failed: audio or video constraint must be specified'); done(); }); }); - it('Should reject with "unsupported media type" when mediaTypes is an empty array', function(done) { + it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are empty', function(done) { initAirConsoleAsController(); - airconsole.requestMediaPermissions([]).then(function(result) { + airconsole.getUserMedia({}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('unsupported media type'); - done(); - }); - }); - - it('Should reject with "unsupported media type" when mediaTypes contains only non-microphone types', function(done) { - initAirConsoleAsController(); - - airconsole.requestMediaPermissions(['camera']).then(function(result) { - expect(result.success).toBe(false); - expect(result.error.message).toBe('unsupported media type'); + expect(result.error.message).toBe('getUserMedia failed: audio or video constraint must be specified'); done(); }); }); it('Should set media_permission_pending_ to true when a valid request is started', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); + spyOn(airconsole, 'sendEvent_'); - airconsole.requestMediaPermissions(['microphone']); + airconsole.getUserMedia({audio: true}); expect(airconsole.media_permission_pending_).toBe(true); done(); }); - it('Should call set_("operation", ...) with correct payload when valid', function(done) { + it('Should call sendEvent_(...) with correct payload when valid', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); + spyOn(airconsole, 'sendEvent_'); - airconsole.requestMediaPermissions(['microphone']); + airconsole.getUserMedia({audio: true}); - expect(airconsole.set_).toHaveBeenCalledWith('operation', jasmine.objectContaining({ - name: 'request-microphone-permission', - data: jasmine.objectContaining({ mediaTypes: ['microphone'] }) - })); + expect(airconsole.sendEvent_).toHaveBeenCalledWith( + 'request-media-permission', + jasmine.objectContaining({ constraints: { audio: true } }) + ); done(); }); - // Group 2: _resolveMediaPermission_ / operation handler responses + // Group 2: _resolveMediaPermission_ / event handler responses it('Should resolve with {success: false, error} on microphone-permission-denied operation', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); - - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('Permission denied'); + expect(result.error.message).toBe('getUserMedia failed: Permission denied'); done(); }); - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-denied' }); + dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-denied' }); }); it('Should resolve with {success: true, stream: } on microphone-permission-granted when getUserMedia succeeds with audio tracks', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); - + var fakeStream = { getAudioTracks: function() { return [{}]; } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(true); expect(result.stream).toBe(fakeStream); done(); }); - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-granted' }); }); - it('Should resolve with {success: false, error} on microphone-permission-granted when getUserMedia resolves but stream has no audio tracks', function(done) { - initAirConsoleAsController(); - spyOn(airconsole, 'set_'); - - var fakeStream = { getAudioTracks: function() { return []; } }; - spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - - airconsole.requestMediaPermissions(['microphone']).then(function(result) { - expect(result.success).toBe(false); - expect(result.error.message).toBe('No audio tracks'); - done(); - }); - - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); - }); it('Should resolve with {success: false, error: err} on microphone-permission-granted when getUserMedia rejects', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); - - var testError = new Error('getUserMedia error'); + + const testError = new Error('getUserMedia error'); spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.reject(testError)); - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio:true}).then(function(result) { expect(result.success).toBe(false); expect(result.error).toBe(testError); done(); }); - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-granted' }); }); it('Should resolve with {success: true, stream: } on microphone-permission-undefined when getUserMedia succeeds with audio tracks', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); - - var fakeStream = { getAudioTracks: function() { return [{}]; } }; + + const fakeStream = { getAudioTracks: function() { return [{}]; } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio:true}).then(function(result) { expect(result.success).toBe(true); expect(result.stream).toBe(fakeStream); done(); }); - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-undefined' }); + dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-undefined' }); }); it('Should clear media_permission_pending_ after resolution', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); + spyOn(airconsole, 'sendEvent_'); var fakeStream = { getAudioTracks: function() { return [{}]; } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { expect(airconsole.media_permission_pending_).toBe(false); done(); }); - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-granted' }); + dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-granted' }); }); it('Should handle double-call to _resolveMediaPermission_ safely (second call is no-op)', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); + spyOn(airconsole, 'sendEvent_'); var resolveCallCount = 0; - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio: true}).then(function(result) { resolveCallCount++; expect(resolveCallCount).toBe(1); expect(result.success).toBe(false); - expect(result.error.message).toBe('Permission denied'); + expect(result.error.message).toBe('getUserMedia failed: Permission denied'); // Now call _resolveMediaPermission_ again - this should be no-op airconsole._resolveMediaPermission_({ success: true }); @@ -233,17 +204,17 @@ function testMediaPermissions() { }, 50); }); - dispatchCustomMessageEvent({ action: 'operation', name: 'microphone-permission-denied' }); + dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-denied' }); }); // Group 3: timeout it('Should resolve with {success: false, error: {message: "timeout"}} after 30000ms', function(done) { initAirConsoleAsController(); - spyOn(airconsole, 'set_'); + spyOn(airconsole, 'sendEvent_'); jasmine.clock().install(); - airconsole.requestMediaPermissions(['microphone']).then(function(result) { + airconsole.getUserMedia({audio:true}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('timeout'); jasmine.clock().uninstall(); From 451fff80d218f3e20552c9dee0de19fa012040db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:00:48 +0100 Subject: [PATCH 04/11] r: Unify audio - video paths r: externalize the decision on hard vs soft fail --- beta/airconsole-1.11.0.js | 64 ++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index d5582a0..788883e 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -697,21 +697,32 @@ AirConsole.prototype.vibrate = function(options) { * ------------------------------------------------------------------------- */ /** - * Gets called on the game screen when a controller is granted microphone - * access as a result of calling requestMicrophoneAccess on that controller. + * Gets called on the game screen when a controller is granted user media + * access as a result of calling getUserMedia on that controller. * @abstract * @param {number} device_id - The device_id of the controller that was granted access. */ -AirConsole.prototype.onMicrophoneAccessGranted = function(device_id) {}; +AirConsole.prototype.onUserMediaAccessGranted = function(device_id) {}; /** - * Gets called on the game screen when microphone access is denied or - * subsequently lost for a controller that called requestMicrophoneAccess. + * @typedef {string} AirConsole~MicDenialReason + * @enum {string} + * @property {string} DENIED_BY_USER - The user denied the permission request. + * @property {string} NOT_SUPPORTED - The browser does not support the requested media type. + * @property {string} AIRCONSOLE_NOT_READY - The controller called getUserMedia before onReady was called or after the device got disconnected. + * @property {string} REQUEST_PENDING - The controller called getUserMedia while another getUserMedia request is still pending. + * @property {string} INVALID_CONSTRAINTS - The controller called getUserMedia with invalid constraints (e.g. no audio or video constraint specified). + * @property {string} TIMEOUT - The user did not respond to the permission request in time. + */ + +/** + * Gets called on the game screen when user media access is denied or + * subsequently lost for a controller that called getUserMedia. * @abstract * @param {number} device_id - The device_id of the controller. * @param {AirConsole~MicDenialReason} reason - The reason for denial or loss. */ -AirConsole.prototype.onMicrophoneAccessDenied = function(device_id, reason) {}; +AirConsole.prototype.onUserMediaAccessDenied = function(device_id, reason) {}; /** * @typedef {Object} AirConsole~GetUserMediaConstraint @@ -746,6 +757,7 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { resolve({ success: false, error: new Error('getUserMedia failed: audio or video constraint must be specified') }); return; } + me.media_permission_constraints_ = constraints; me.media_permission_pending_ = true; me.media_permission_resolve_ = resolve; me.media_permission_timeout_ = setTimeout(function() { @@ -761,6 +773,8 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { AirConsole.prototype._resolveMediaPermission_ = function _resolveMediaPermission_(result) { clearTimeout(this.media_permission_timeout_); this.media_permission_pending_ = false; + this.media_permission_constraints_ = undefined; + this.resolveMediaPermissionError_ = undefined; this.media_permission_timeout_ = undefined; const resolve = this.media_permission_resolve_; this.media_permission_resolve_ = undefined; @@ -1433,11 +1447,11 @@ AirConsole.prototype.onPostMessage_ = function(event) { if (data.device_data._is_profile_update) { me.onDeviceProfileChange(sender); } - if (data.device_data._is_multimedia_update) { - if (data.device_data.microphone && data.device_data.microphone.granted === true) { - me.onMicrophoneAccessGranted(sender); - } else if (data.device_data.microphone && data.device_data.microphone.granted === false) { - me.onMicrophoneAccessDenied(sender, data.device_data.microphone.reason); + if (data.device_data._is_usermediapermission_update) { + if (data.device_data.userMediaPermission && data.device_data.userMediaPermission.granted === true) { + me.onUserMediaAccessGranted(sender); + } else if (data.device_data.userMediaPermission && data.device_data.userMediaPermission.granted === false) { + me.onUserMediaAccessDenied(sender, data.device_data.userMediaPermission.reason); } } } @@ -1540,28 +1554,30 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.onSetSafeArea(data.gameSafeArea); } else if (data.action === 'event') { const { type } = data; - if (type === 'microphone-permission-denied') { - me._resolveMediaPermission_({success: false, error: new Error('getUserMedia failed: Permission denied')}); - } else if (type === 'microphone-permission-granted' || type === 'microphone-permission-undefined') { + if (type === 'usermedia-permission-denied') { + const { denial, error } = data.data; + me._resolveMediaPermission_({ + success: false, + reason: denial ? 'denied-permanent' : 'denied-temporary', + error: me.resolveMediaPermissionError_ + }); + } else if (type === 'usermedia-permission-granted' || type === 'usermedia-permission-prompt') { const userPromptStartTime = performance.now(); - navigator.mediaDevices.getUserMedia({audio: true, video: false}).then( + navigator.mediaDevices.getUserMedia(me.media_permission_constraints_).then( function success(stream) { me._resolveMediaPermission_({success: true, stream: stream}); - me.sendEvent_('microphone-permission-granted', {}); + me.sendEvent_('usermedia-permission-user-granted', {}); }, function failure(err) { // Native controller - if (type === 'microphone-permission-granted') { + if (type === 'usermedia-permission-granted') { me._resolveMediaPermission_({success: false, error: err}); - } else if (type === 'microphone-permission-undefined') { + } else if (type === 'usermedia-permission-prompt') { + me.resolveMediaPermissionError_ = err; // web based controller const userPromptDuration = performance.now() - userPromptStartTime; if (err.name === 'NotAllowedError') { - if (userPromptDuration < 300) { - me.sendEvent_('microphone-permission-user-hard-denied', {}); - } else { - me.sendEvent_('microphone-permission-user-soft-denied', {}); - } + me.sendEvent_('usermedia-permission-user-denied', { userPromptDuration}); } } } @@ -1646,7 +1662,7 @@ AirConsole.prototype.set_ = function(key, value) { * @param {serializable} eventData - The data of the event. Must be serializable. * @private */ -AirConsole.prototype.sendEvent_ = (eventType, eventData) => { +AirConsole.prototype.sendEvent_ = function(eventType, eventData) { AirConsole.postMessage_({ action: 'event', type: eventType, data: eventData }); }; From 9c844d4c44d95af7a3c99bdc7a996271209dd606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:17:01 +0100 Subject: [PATCH 05/11] ! B: Fix test running anomalies and update tests to current state --- tests/airconsole-1.11.0-spec.html | 2 +- tests/spec/airconsole-1.11.0-spec.js | 4 +- ...sions.js => spec-usermedia-permissions.js} | 166 ++++++++---------- 3 files changed, 79 insertions(+), 93 deletions(-) rename tests/spec/methods/{spec-media-permissions.js => spec-usermedia-permissions.js} (62%) diff --git a/tests/airconsole-1.11.0-spec.html b/tests/airconsole-1.11.0-spec.html index 8a96cad..501f410 100644 --- a/tests/airconsole-1.11.0-spec.html +++ b/tests/airconsole-1.11.0-spec.html @@ -29,7 +29,7 @@ - + diff --git a/tests/spec/airconsole-1.11.0-spec.js b/tests/spec/airconsole-1.11.0-spec.js index 1b0533e..88f3711 100644 --- a/tests/spec/airconsole-1.11.0-spec.js +++ b/tests/spec/airconsole-1.11.0-spec.js @@ -390,11 +390,11 @@ describe("AirConsole 1.11.0", function () { TEST MEDIA PERMISSIONS FUNCTIONALITY */ - describe("Media Permissions", function () { + describe("User Media Permissions", function () { afterEach(function () { tearDown(); }); - testMediaPermissions(); + testUserMediaPermissions(); }); }); diff --git a/tests/spec/methods/spec-media-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js similarity index 62% rename from tests/spec/methods/spec-media-permissions.js rename to tests/spec/methods/spec-usermedia-permissions.js index 250de18..7e8b90b 100644 --- a/tests/spec/methods/spec-media-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -1,4 +1,4 @@ -function testMediaPermissions() { +function testUserMediaPermissions() { function initAirConsoleAsController() { spyOn(document, 'getElementsByTagName').and.callFake(function() { return [{ src: 'http://localhost/api/airconsole-latest.js' }]; @@ -9,12 +9,20 @@ function testMediaPermissions() { airconsole.devices[DEVICE_ID] = { uid: 1237, nicktype: 'Sergio', location: LOCATION, custom: {} }; } + beforeEach(function() { + jasmine.clock().install(); // ← install for ALL tests + initAirConsoleAsController(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); // ← uninstall after ALL tests + }); + // Group 1: Early rejections (sync, resolve immediately) - + it('Should reject with "getUserMedia is not supported on screen" when device_id === AirConsole.SCREEN', function(done) { - initAirConsoleAsController(); airconsole.device_id = AirConsole.SCREEN; - + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: getUserMedia is not supported on screen'); @@ -23,9 +31,8 @@ function testMediaPermissions() { }); it('Should reject with "AirConsole not ready" when device_id === undefined', function(done) { - initAirConsoleAsController(); airconsole.device_id = undefined; - + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: AirConsole not ready'); @@ -34,9 +41,8 @@ function testMediaPermissions() { }); it('Should reject with "AirConsole not ready" when device_id === null', function(done) { - initAirConsoleAsController(); airconsole.device_id = null; - + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: AirConsole not ready'); @@ -45,9 +51,8 @@ function testMediaPermissions() { }); it('Should reject with "Request already in progress" when media_permission_pending_ is already true', function(done) { - initAirConsoleAsController(); airconsole.media_permission_pending_ = true; - + airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: Request already in progress'); @@ -56,8 +61,7 @@ function testMediaPermissions() { }); it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are null', function(done) { - initAirConsoleAsController(); - + airconsole.getUserMedia(null).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: audio or video constraint must be specified'); @@ -66,8 +70,7 @@ function testMediaPermissions() { }); it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are undefined', function(done) { - initAirConsoleAsController(); - + airconsole.getUserMedia(undefined).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: audio or video constraint must be specified'); @@ -76,8 +79,7 @@ function testMediaPermissions() { }); it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are empty', function(done) { - initAirConsoleAsController(); - + airconsole.getUserMedia({}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('getUserMedia failed: audio or video constraint must be specified'); @@ -86,21 +88,19 @@ function testMediaPermissions() { }); it('Should set media_permission_pending_ to true when a valid request is started', function(done) { - initAirConsoleAsController(); spyOn(airconsole, 'sendEvent_'); - + airconsole.getUserMedia({audio: true}); - + expect(airconsole.media_permission_pending_).toBe(true); done(); }); it('Should call sendEvent_(...) with correct payload when valid', function(done) { - initAirConsoleAsController(); spyOn(airconsole, 'sendEvent_'); - + airconsole.getUserMedia({audio: true}); - + expect(airconsole.sendEvent_).toHaveBeenCalledWith( 'request-media-permission', jasmine.objectContaining({ constraints: { audio: true } }) @@ -110,117 +110,103 @@ function testMediaPermissions() { // Group 2: _resolveMediaPermission_ / event handler responses - it('Should resolve with {success: false, error} on microphone-permission-denied operation', function(done) { - initAirConsoleAsController(); - + it('Should resolve with {success: false, reason} on usermedia-permission-denied operation', function(done) { airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.error.message).toBe('getUserMedia failed: Permission denied'); + expect(result.reason).toBe('denied-temporary'); done(); }); - - dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-denied' }); + + dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-denied', data: { denial: false } }); }); - it('Should resolve with {success: true, stream: } on microphone-permission-granted when getUserMedia succeeds with audio tracks', function(done) { - initAirConsoleAsController(); + it('Should resolve with {success: true, stream: } on usermedia-permission-granted when getUserMedia succeeds with audio tracks', async function() { - var fakeStream = { getAudioTracks: function() { return [{}]; } }; + const fakeStream = { + getAudioTracks: function() { + return [{}]; + } + }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - - airconsole.getUserMedia({audio: true}).then(function(result) { - expect(result.success).toBe(true); - expect(result.stream).toBe(fakeStream); - done(); - }); - - dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-granted' }); + dispatchCustomMessageEvent({action: 'event', type: 'usermedia-permission-granted'}); + + const result = await airconsole.getUserMedia({audio: true}); + expect(result.success).toBe(true); + expect(result.stream).toBe(fakeStream); }); + it('Should resolve with {success: false, error: err} on usermedia-permission-granted when getUserMedia rejects', async function() { - it('Should resolve with {success: false, error: err} on microphone-permission-granted when getUserMedia rejects', function(done) { - initAirConsoleAsController(); + const testError = new Error('getUserMedia failed: Permission denied'); + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.reject(testError)); - const testError = new Error('getUserMedia error'); - spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.reject(testError)); - - airconsole.getUserMedia({audio:true}).then(function(result) { - expect(result.success).toBe(false); - expect(result.error).toBe(testError); - done(); + dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-granted' }); + + const result = await airconsole.getUserMedia({audio:true}); //.then(function(result) { + expect(result.success).toBe(false); + expect(result.error).toBe(testError); }); - - dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-granted' }); - }); - it('Should resolve with {success: true, stream: } on microphone-permission-undefined when getUserMedia succeeds with audio tracks', function(done) { - initAirConsoleAsController(); + it('Should resolve with {success: true, stream: } on usermedia-permission-prompt when getUserMedia succeeds with audio tracks', function(done) { const fakeStream = { getAudioTracks: function() { return [{}]; } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - + airconsole.getUserMedia({audio:true}).then(function(result) { expect(result.success).toBe(true); expect(result.stream).toBe(fakeStream); done(); }); - - dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-undefined' }); + + dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-prompt' }); }); it('Should clear media_permission_pending_ after resolution', function(done) { - initAirConsoleAsController(); spyOn(airconsole, 'sendEvent_'); - + var fakeStream = { getAudioTracks: function() { return [{}]; } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - + airconsole.getUserMedia({audio: true}).then(function(result) { expect(airconsole.media_permission_pending_).toBe(false); done(); }); - - dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-granted' }); - }); - it('Should handle double-call to _resolveMediaPermission_ safely (second call is no-op)', function(done) { - initAirConsoleAsController(); - spyOn(airconsole, 'sendEvent_'); - - var resolveCallCount = 0; - airconsole.getUserMedia({audio: true}).then(function(result) { - resolveCallCount++; - expect(resolveCallCount).toBe(1); - expect(result.success).toBe(false); - expect(result.error.message).toBe('getUserMedia failed: Permission denied'); - - // Now call _resolveMediaPermission_ again - this should be no-op - airconsole._resolveMediaPermission_({ success: true }); - - // After a small delay, verify resolveCallCount is still 1 - setTimeout(function() { - expect(resolveCallCount).toBe(1); - done(); - }, 50); - }); - - dispatchCustomMessageEvent({ action: 'event', type: 'microphone-permission-denied' }); + dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-granted' }); }); - // Group 3: timeout + it('Should handle double-call to _resolveMediaPermission_ safely (second call is no-op)', function(done) { + spyOn(airconsole, 'sendEvent_'); + + var resolveCallCount = 0; + airconsole.getUserMedia({audio: true}).then(function(result) { + resolveCallCount++; + expect(resolveCallCount).toBe(1); + expect(result.success).toBe(false); + expect(result.reason).toBe('denied-temporary'); + + // Now call _resolveMediaPermission_ again - this should be no-op + airconsole._resolveMediaPermission_({ success: true }); + + // Immediately verify resolveCallCount is still 1 + expect(resolveCallCount).toBe(1); + done(); + }); + + dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-denied', data: { denial: false } }); + }); + + // Group 3: Timeout it('Should resolve with {success: false, error: {message: "timeout"}} after 30000ms', function(done) { - initAirConsoleAsController(); spyOn(airconsole, 'sendEvent_'); - jasmine.clock().install(); - + airconsole.getUserMedia({audio:true}).then(function(result) { expect(result.success).toBe(false); expect(result.error.message).toBe('timeout'); - jasmine.clock().uninstall(); done(); }); - + jasmine.clock().tick(30001); }); } From 445fd1837d8726ad5077c28f75f825632f7eed4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:38:07 +0100 Subject: [PATCH 06/11] r: Update airconsole-1.11.0 api to new flow --- beta/airconsole-1.11.0.js | 29 ++++++++++--------- .../methods/spec-usermedia-permissions.js | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index 788883e..00efe1d 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -1447,11 +1447,19 @@ AirConsole.prototype.onPostMessage_ = function(event) { if (data.device_data._is_profile_update) { me.onDeviceProfileChange(sender); } - if (data.device_data._is_usermediapermission_update) { - if (data.device_data.userMediaPermission && data.device_data.userMediaPermission.granted === true) { - me.onUserMediaAccessGranted(sender); - } else if (data.device_data.userMediaPermission && data.device_data.userMediaPermission.granted === false) { - me.onUserMediaAccessDenied(sender, data.device_data.userMediaPermission.reason); + if (data.device_data._is_userMediaPermission_update) { + if (!!data.device_data.userMediaPermission) { + const { granted, denial, error } = data.device_data.userMediaPermission; + if (granted) { + me.onUserMediaAccessGranted(sender); + } else { + me._resolveMediaPermission_({ + success: false, + reason: denial ? 'denied-permanent' : 'denied-temporary', + error: me.resolveMediaPermissionError_ + }); + me.onUserMediaAccessDenied(sender, data.device_data.userMediaPermission.reason); + } } } } @@ -1554,14 +1562,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.onSetSafeArea(data.gameSafeArea); } else if (data.action === 'event') { const { type } = data; - if (type === 'usermedia-permission-denied') { - const { denial, error } = data.data; - me._resolveMediaPermission_({ - success: false, - reason: denial ? 'denied-permanent' : 'denied-temporary', - error: me.resolveMediaPermissionError_ - }); - } else if (type === 'usermedia-permission-granted' || type === 'usermedia-permission-prompt') { + if (type === 'usermedia-permission-granted' || type === 'usermedia-permission-prompt') { const userPromptStartTime = performance.now(); navigator.mediaDevices.getUserMedia(me.media_permission_constraints_).then( function success(stream) { @@ -1577,7 +1578,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { // web based controller const userPromptDuration = performance.now() - userPromptStartTime; if (err.name === 'NotAllowedError') { - me.sendEvent_('usermedia-permission-user-denied', { userPromptDuration}); + me.sendEvent_('usermedia-permission-user-denied', {userPromptDuration}); } } } diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index 7e8b90b..285b735 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -142,7 +142,7 @@ function testUserMediaPermissions() { dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-granted' }); - const result = await airconsole.getUserMedia({audio:true}); //.then(function(result) { + const result = await airconsole.getUserMedia({audio:true}); expect(result.success).toBe(false); expect(result.error).toBe(testError); }); From f4fbdc384b463d5b96eab0230b7cbb675c768eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:34:06 +0200 Subject: [PATCH 07/11] f B: Add getConfiguration() to airconsole-api 1.10.0 and 1.11.0 Store configuration from the ready event and expose it via getConfiguration(). Returns {supportedVideoFormats, transparentVideoSupported, unityVideoSupported, graphicsQualityTier} or undefined before onReady. Adds Jasmine tests for both present and absent configuration. ENG-74 Task 3 --- airconsole-1.10.0.js | 16 +++++++++++++++ beta/airconsole-1.11.0.js | 16 +++++++++++++++ tests/spec/methods/spec-connectivity.js | 27 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/airconsole-1.10.0.js b/airconsole-1.10.0.js index 7c7c4fb..446cee0 100644 --- a/airconsole-1.10.0.js +++ b/airconsole-1.10.0.js @@ -249,6 +249,21 @@ AirConsole.prototype.arePlayersSilenced = function () { && (this.devices[AirConsole.SCREEN]["players"] !== undefined && this.devices[AirConsole.SCREEN]["players"].length > 0); } +/** + * Returns the platform capability configuration. + * Use this to branch on capabilities instead of platform or partner names. + * Can only be called after onReady. + * @return {Object|undefined} An object with: + * supportedVideoFormats {string[]} - e.g. ["vp9","h264","vp8"] + * transparentVideoSupported {boolean} + * unityVideoSupported {boolean} + * graphicsQualityTier {string} - "low", "medium", or "high" + * @since 1.10.0 + */ +AirConsole.prototype.getConfiguration = function() { + return this.configuration; +} + /** * Dictionary of silenced update messages queued during a running game session. * @private @@ -1365,6 +1380,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } me.gameSafeArea = data.gameSafeArea; + me.configuration = data.configuration; if (data.translations) { me.translations = data.translations; var elements = document.querySelectorAll("[data-translation]"); diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index 00efe1d..63cc30c 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -249,6 +249,21 @@ AirConsole.prototype.arePlayersSilenced = function () { && (this.devices[AirConsole.SCREEN]["players"] !== undefined && this.devices[AirConsole.SCREEN]["players"].length > 0); } +/** + * Returns the platform capability configuration. + * Use this to branch on capabilities instead of platform or partner names. + * Can only be called after onReady. + * @return {Object|undefined} An object with: + * supportedVideoFormats {string[]} - e.g. ["vp9","h264","vp8"] + * transparentVideoSupported {boolean} + * unityVideoSupported {boolean} + * graphicsQualityTier {string} - "low", "medium", or "high" + * @since 1.10.0 + */ +AirConsole.prototype.getConfiguration = function() { + return this.configuration; +} + /** * Dictionary of silenced update messages queued during a running game session. * @private @@ -1472,6 +1487,7 @@ AirConsole.prototype.onPostMessage_ = function(event) { } me.gameSafeArea = data.gameSafeArea; + me.configuration = data.configuration; if (data.translations) { me.translations = data.translations; var elements = document.querySelectorAll("[data-translation]"); diff --git a/tests/spec/methods/spec-connectivity.js b/tests/spec/methods/spec-connectivity.js index 778398d..ea7d047 100644 --- a/tests/spec/methods/spec-connectivity.js +++ b/tests/spec/methods/spec-connectivity.js @@ -88,4 +88,31 @@ function testConnectivity(overwrite_its, params) { expect(airconsole.onCustomDeviceStateChange).toHaveBeenCalledWith(DEVICE_ID, custom_data); }); + it ("Should store configuration from ready event", function() { + var configuration = { + supportedVideoFormats: ["vp9", "h264", "vp8"], + transparentVideoSupported: true, + unityVideoSupported: true, + graphicsQualityTier: "high" + }; + dispatchCustomMessageEvent({ + action: "ready", + code: 1237, + device_id: 0, + devices: [{}, undefined, airconsole.devices[DEVICE_ID]], + configuration: configuration + }); + expect(airconsole.getConfiguration()).toEqual(configuration); + }); + + it ("Should return undefined configuration when not provided in ready event", function() { + dispatchCustomMessageEvent({ + action: "ready", + code: 1237, + device_id: 0, + devices: [{}, undefined, airconsole.devices[DEVICE_ID]] + }); + expect(airconsole.getConfiguration()).toBeUndefined(); + }); + } From d278a7f9a84cc1a531ff7921173a2b27b5260886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:52:33 +0200 Subject: [PATCH 08/11] d B: Improve getConfiguration() JSDoc with screen-only note (ENG-74, Task 6) Clarify that getConfiguration() is only available on the screen device, returns undefined on controllers, and is delivered via the ready event. --- airconsole-1.10.0.js | 6 ++++-- beta/airconsole-1.11.0.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/airconsole-1.10.0.js b/airconsole-1.10.0.js index 446cee0..a8906e6 100644 --- a/airconsole-1.10.0.js +++ b/airconsole-1.10.0.js @@ -250,14 +250,16 @@ AirConsole.prototype.arePlayersSilenced = function () { } /** - * Returns the platform capability configuration. - * Use this to branch on capabilities instead of platform or partner names. + * Returns the platform capability configuration delivered in the ready event. + * Use this to branch on device capabilities instead of platform or partner + * names. Only available on the screen; controllers receive undefined. * Can only be called after onReady. * @return {Object|undefined} An object with: * supportedVideoFormats {string[]} - e.g. ["vp9","h264","vp8"] * transparentVideoSupported {boolean} * unityVideoSupported {boolean} * graphicsQualityTier {string} - "low", "medium", or "high" + * Returns undefined on controllers or if the platform did not send it. * @since 1.10.0 */ AirConsole.prototype.getConfiguration = function() { diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index 63cc30c..0686be1 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -250,14 +250,16 @@ AirConsole.prototype.arePlayersSilenced = function () { } /** - * Returns the platform capability configuration. - * Use this to branch on capabilities instead of platform or partner names. + * Returns the platform capability configuration delivered in the ready event. + * Use this to branch on device capabilities instead of platform or partner + * names. Only available on the screen; controllers receive undefined. * Can only be called after onReady. * @return {Object|undefined} An object with: * supportedVideoFormats {string[]} - e.g. ["vp9","h264","vp8"] * transparentVideoSupported {boolean} * unityVideoSupported {boolean} * graphicsQualityTier {string} - "low", "medium", or "high" + * Returns undefined on controllers or if the platform did not send it. * @since 1.10.0 */ AirConsole.prototype.getConfiguration = function() { From 5641bed536c9e070d60f25fd9c1e07d755a709f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:18:40 +0200 Subject: [PATCH 09/11] t B: Add pre-onReady getConfiguration() test (M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jasmine test verifying getConfiguration() returns undefined before the onReady READY message is received — closes review finding M2. --- tests/spec/methods/spec-connectivity.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/spec/methods/spec-connectivity.js b/tests/spec/methods/spec-connectivity.js index ea7d047..920740c 100644 --- a/tests/spec/methods/spec-connectivity.js +++ b/tests/spec/methods/spec-connectivity.js @@ -115,4 +115,10 @@ function testConnectivity(overwrite_its, params) { expect(airconsole.getConfiguration()).toBeUndefined(); }); + it ("Should return undefined configuration before onReady fires", function() { + // getConfiguration() must return undefined until the READY message has been processed; + // a freshly-constructed AirConsole instance has not yet received a ready event. + expect(airconsole.getConfiguration()).toBeUndefined(); + }); + } From 0b59523e851e118594bf7b271741c41c5234012e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:14:25 +0200 Subject: [PATCH 10/11] d: Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aded1cd..c73b4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Release notes follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/ ### Added +- Added `AirConsole.getConfiguration()` to expose the platform capability configuration from the `ready` event on screens. + ## [1.10.0] - 2026-02-17 ### Added From 60772b735afc5a68f015f0740a1330176000fba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4rer?= <143824611+marc-n-dream@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:14:56 +0200 Subject: [PATCH 11/11] test(configuration): extract getConfiguration tests for version gating --- tests/airconsole-1.10.0-spec.html | 5 ++-- tests/airconsole-1.11.0-spec.html | 5 ++-- tests/spec/airconsole-1.10.0-spec.js | 17 +++++++++++ tests/spec/airconsole-1.11.0-spec.js | 17 +++++++++++ tests/spec/methods/spec-configuration.js | 36 ++++++++++++++++++++++++ tests/spec/methods/spec-connectivity.js | 33 ---------------------- 6 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 tests/spec/methods/spec-configuration.js diff --git a/tests/airconsole-1.10.0-spec.html b/tests/airconsole-1.10.0-spec.html index fbd6136..8c84c22 100644 --- a/tests/airconsole-1.10.0-spec.html +++ b/tests/airconsole-1.10.0-spec.html @@ -28,9 +28,10 @@ - + + - + diff --git a/tests/airconsole-1.11.0-spec.html b/tests/airconsole-1.11.0-spec.html index 896a6de..f4973e2 100644 --- a/tests/airconsole-1.11.0-spec.html +++ b/tests/airconsole-1.11.0-spec.html @@ -30,9 +30,10 @@ - + + - + diff --git a/tests/spec/airconsole-1.10.0-spec.js b/tests/spec/airconsole-1.10.0-spec.js index a5be8e8..9502e6d 100644 --- a/tests/spec/airconsole-1.10.0-spec.js +++ b/tests/spec/airconsole-1.10.0-spec.js @@ -384,4 +384,21 @@ describe("AirConsole 1.10.0", function () { testAirConsole110Plus(); }); + + /** + ====================================================================================== + TEST CONFIGURATION FUNCTIONALITY + */ + + describe("Configuration", function () { + beforeEach(function () { + initAirConsole(); + }); + + afterEach(function () { + tearDown(); + }); + + testGetConfiguration(); + }); }); diff --git a/tests/spec/airconsole-1.11.0-spec.js b/tests/spec/airconsole-1.11.0-spec.js index 664c738..25f62af 100644 --- a/tests/spec/airconsole-1.11.0-spec.js +++ b/tests/spec/airconsole-1.11.0-spec.js @@ -397,4 +397,21 @@ describe("AirConsole 1.11.0", function () { testUserMediaPermissions(); }); + + /** + ====================================================================================== + TEST CONFIGURATION FUNCTIONALITY + */ + + describe("Configuration", function () { + beforeEach(function () { + initAirConsole(); + }); + + afterEach(function () { + tearDown(); + }); + + testGetConfiguration(); + }); }); diff --git a/tests/spec/methods/spec-configuration.js b/tests/spec/methods/spec-configuration.js new file mode 100644 index 0000000..44f2df8 --- /dev/null +++ b/tests/spec/methods/spec-configuration.js @@ -0,0 +1,36 @@ +function testGetConfiguration() { + + it ("Should store configuration from ready event", function() { + var configuration = { + supportedVideoFormats: ["vp9", "h264", "vp8"], + transparentVideoSupported: true, + unityVideoSupported: true, + graphicsQualityTier: "high" + }; + dispatchCustomMessageEvent({ + action: "ready", + code: 1237, + device_id: 0, + devices: [{}, undefined, airconsole.devices[DEVICE_ID]], + configuration: configuration + }); + expect(airconsole.getConfiguration()).toEqual(configuration); + }); + + it ("Should return undefined configuration when not provided in ready event", function() { + dispatchCustomMessageEvent({ + action: "ready", + code: 1237, + device_id: 0, + devices: [{}, undefined, airconsole.devices[DEVICE_ID]] + }); + expect(airconsole.getConfiguration()).toBeUndefined(); + }); + + it ("Should return undefined configuration before onReady fires", function() { + // getConfiguration() must return undefined until the READY message has been processed; + // a freshly-constructed AirConsole instance has not yet received a ready event. + expect(airconsole.getConfiguration()).toBeUndefined(); + }); + +} diff --git a/tests/spec/methods/spec-connectivity.js b/tests/spec/methods/spec-connectivity.js index 920740c..778398d 100644 --- a/tests/spec/methods/spec-connectivity.js +++ b/tests/spec/methods/spec-connectivity.js @@ -88,37 +88,4 @@ function testConnectivity(overwrite_its, params) { expect(airconsole.onCustomDeviceStateChange).toHaveBeenCalledWith(DEVICE_ID, custom_data); }); - it ("Should store configuration from ready event", function() { - var configuration = { - supportedVideoFormats: ["vp9", "h264", "vp8"], - transparentVideoSupported: true, - unityVideoSupported: true, - graphicsQualityTier: "high" - }; - dispatchCustomMessageEvent({ - action: "ready", - code: 1237, - device_id: 0, - devices: [{}, undefined, airconsole.devices[DEVICE_ID]], - configuration: configuration - }); - expect(airconsole.getConfiguration()).toEqual(configuration); - }); - - it ("Should return undefined configuration when not provided in ready event", function() { - dispatchCustomMessageEvent({ - action: "ready", - code: 1237, - device_id: 0, - devices: [{}, undefined, airconsole.devices[DEVICE_ID]] - }); - expect(airconsole.getConfiguration()).toBeUndefined(); - }); - - it ("Should return undefined configuration before onReady fires", function() { - // getConfiguration() must return undefined until the READY message has been processed; - // a freshly-constructed AirConsole instance has not yet received a ready event. - expect(airconsole.getConfiguration()).toBeUndefined(); - }); - }