From a9d758c30783d429b6919e92fa64770e4d3c4cf9 Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Thu, 12 Mar 2026 03:06:49 +0100 Subject: [PATCH 1/9] ! 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 aa1acc6c99a7e42da39581980b4ea6023fd6752f Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Thu, 12 Mar 2026 04:22:40 +0100 Subject: [PATCH 2/9] 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 39291f3c3b90a2577bf3e55ef54b4abfb2221bf1 Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Tue, 17 Mar 2026 16:39:23 +0100 Subject: [PATCH 3/9] 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 1f81ed0bba760881807a0ed21843977f105fdb05 Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Wed, 18 Mar 2026 13:00:48 +0100 Subject: [PATCH 4/9] 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 c0f73a0c011ea6a48036f4dbc4636f3ea0651920 Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Wed, 18 Mar 2026 16:17:01 +0100 Subject: [PATCH 5/9] ! 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 998a717410cf4d9e117fb4f45712892c50f86084 Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Thu, 19 Mar 2026 17:38:07 +0100 Subject: [PATCH 6/9] 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 1988e737ed7fada38e87760b759a61334828c443 Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Fri, 20 Mar 2026 02:06:20 +0100 Subject: [PATCH 7/9] r: Update event names for user media r: Move denial handling back into onPostMessage_ control flow --- beta/airconsole-1.11.0.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index 00efe1d..e0cd1dc 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -766,7 +766,7 @@ AirConsole.prototype.getUserMedia = function getUserMedia(constraints) { // Send the request to the platform to decide where and how the user media request needs to take place based on // browser or controller environment. - me.sendEvent_('request-media-permission', { constraints: constraints }); + me.sendEvent_('requestUserMediaPermission', { constraints: constraints }); }); }; @@ -1453,11 +1453,6 @@ AirConsole.prototype.onPostMessage_ = function(event) { 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); } } @@ -1562,23 +1557,30 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.onSetSafeArea(data.gameSafeArea); } else if (data.action === 'event') { const { type } = data; - if (type === 'usermedia-permission-granted' || type === 'usermedia-permission-prompt') { + if (type === 'userMediaPermissionDenied') { + const { denial, error } = data.data; + me._resolveMediaPermission_({ + success: false, + reason: denial ? 'permanent' : 'temporary', + error: me.resolveMediaPermissionError_ + }); + } else if (type === 'userMediaPermissionGranted' || type === 'promptUserMediaPermission') { const userPromptStartTime = performance.now(); navigator.mediaDevices.getUserMedia(me.media_permission_constraints_).then( function success(stream) { me._resolveMediaPermission_({success: true, stream: stream}); - me.sendEvent_('usermedia-permission-user-granted', {}); + me.sendEvent_('userMediaPermissionGranted', {}); }, function failure(err) { // Native controller - if (type === 'usermedia-permission-granted') { + if (type === 'userMediaPermissionGranted') { me._resolveMediaPermission_({success: false, error: err}); - } else if (type === 'usermedia-permission-prompt') { + } else if (type === 'promptUserMediaPermission') { me.resolveMediaPermissionError_ = err; // web based controller const userPromptDuration = performance.now() - userPromptStartTime; if (err.name === 'NotAllowedError') { - me.sendEvent_('usermedia-permission-user-denied', {userPromptDuration}); + me.sendEvent_('userMediaPermissionDenied', {userPromptDuration}); } } } @@ -1664,7 +1666,7 @@ AirConsole.prototype.set_ = function(key, value) { * @private */ AirConsole.prototype.sendEvent_ = function(eventType, eventData) { - AirConsole.postMessage_({ action: 'event', type: eventType, data: eventData }); + AirConsole.postMessage_({ action: 'event', name: eventType, data: eventData }); }; /** From c0ef4b94814f823b4ec0d35d915493ac99d15b22 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 22 Mar 2026 01:00:24 +0100 Subject: [PATCH 8/9] Changelog: - Updated format to be using event.type again to be consistent and not introduce yet another field describing what the event is - No need for `granted` as the events already make it clear if the mediaPermission was granted or denied - Added constant for `MEDIA_PERMISSION_DENIED` so the game can check against those strings if needed - Fixed incorrect `denial` and `error` destructures, instead using `reason` for when the permission is denied --- beta/airconsole-1.11.0.js | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index e0cd1dc..458f70e 100644 --- a/beta/airconsole-1.11.0.js +++ b/beta/airconsole-1.11.0.js @@ -132,6 +132,14 @@ AirConsole.VIBRATE = { } }; +/** + * TODO + */ +AirConsole.MEDIA_PERMISSION_DENIED = { + temporary: "temporary", + permanent: "permanent", +}; + /** ------------------------------------------------------------------------ * * @chapter CONNECTIVITY * * @see http://developers.airconsole.com/#!/guides/pong * @@ -1449,11 +1457,11 @@ AirConsole.prototype.onPostMessage_ = function(event) { } if (data.device_data._is_userMediaPermission_update) { if (!!data.device_data.userMediaPermission) { - const { granted, denial, error } = data.device_data.userMediaPermission; + const { granted, reason } = data.device_data.userMediaPermission; if (granted) { me.onUserMediaAccessGranted(sender); } else { - me.onUserMediaAccessDenied(sender, data.device_data.userMediaPermission.reason); + me.onUserMediaAccessDenied(sender, reason); } } } @@ -1557,30 +1565,33 @@ AirConsole.prototype.onPostMessage_ = function(event) { me.onSetSafeArea(data.gameSafeArea); } else if (data.action === 'event') { const { type } = data; + if (type === 'userMediaPermissionDenied') { - const { denial, error } = data.data; + const reason = data.data?.reason; + me._resolveMediaPermission_({ success: false, - reason: denial ? 'permanent' : 'temporary', + reason, error: me.resolveMediaPermissionError_ }); } else if (type === 'userMediaPermissionGranted' || type === 'promptUserMediaPermission') { const userPromptStartTime = performance.now(); + navigator.mediaDevices.getUserMedia(me.media_permission_constraints_).then( function success(stream) { - me._resolveMediaPermission_({success: true, stream: stream}); + me._resolveMediaPermission_({ success: true, stream: stream }); me.sendEvent_('userMediaPermissionGranted', {}); }, - function failure(err) { + function failure(error) { // Native controller if (type === 'userMediaPermissionGranted') { - me._resolveMediaPermission_({success: false, error: err}); + me._resolveMediaPermission_({ success: false, error }); } else if (type === 'promptUserMediaPermission') { - me.resolveMediaPermissionError_ = err; - // web based controller + me.resolveMediaPermissionError_ = error; + // Web based controller const userPromptDuration = performance.now() - userPromptStartTime; - if (err.name === 'NotAllowedError') { - me.sendEvent_('userMediaPermissionDenied', {userPromptDuration}); + if (error.name === 'NotAllowedError') { + me.sendEvent_('userMediaPermissionDenied', { userPromptDuration }); } } } @@ -1666,7 +1677,7 @@ AirConsole.prototype.set_ = function(key, value) { * @private */ AirConsole.prototype.sendEvent_ = function(eventType, eventData) { - AirConsole.postMessage_({ action: 'event', name: eventType, data: eventData }); + AirConsole.postMessage_({ action: 'event', type: eventType, data: eventData }); }; /** From 795f2bfbd984b94a9cf1b30e0e787f38f9ef2c7d Mon Sep 17 00:00:00 2001 From: marc-n-dream Date: Thu, 26 Mar 2026 00:34:37 +0100 Subject: [PATCH 9/9] t: Update tests to changed usermedia event api --- .../methods/spec-usermedia-permissions.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js index 285b735..abbaaf1 100644 --- a/tests/spec/methods/spec-usermedia-permissions.js +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -102,7 +102,7 @@ function testUserMediaPermissions() { airconsole.getUserMedia({audio: true}); expect(airconsole.sendEvent_).toHaveBeenCalledWith( - 'request-media-permission', + 'requestUserMediaPermission', jasmine.objectContaining({ constraints: { audio: true } }) ); done(); @@ -110,17 +110,17 @@ function testUserMediaPermissions() { // Group 2: _resolveMediaPermission_ / event handler responses - it('Should resolve with {success: false, reason} on usermedia-permission-denied operation', function(done) { + it('Should resolve with {success: false, reason} on userMediaPermissionDenied operation', function(done) { airconsole.getUserMedia({audio: true}).then(function(result) { expect(result.success).toBe(false); - expect(result.reason).toBe('denied-temporary'); + expect(result.reason).toBe('temporary'); done(); }); - dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-denied', data: { denial: false } }); + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionDenied', data: { reason: AirConsole.MEDIA_PERMISSION_DENIED.temporary } }); }); - it('Should resolve with {success: true, stream: } on usermedia-permission-granted when getUserMedia succeeds with audio tracks', async function() { + it('Should resolve with {success: true, stream: } on userMediaPermissionGranted when getUserMedia succeeds with audio tracks', async function() { const fakeStream = { getAudioTracks: function() { @@ -128,26 +128,26 @@ function testUserMediaPermissions() { } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); - dispatchCustomMessageEvent({action: 'event', type: 'usermedia-permission-granted'}); + dispatchCustomMessageEvent({action: 'event', type: 'userMediaPermissionGranted'}); 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 userMediaPermissionGranted when getUserMedia rejects', async function() { const testError = new Error('getUserMedia failed: Permission denied'); spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.reject(testError)); - dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-granted' }); + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionGranted' }); const result = await airconsole.getUserMedia({audio:true}); expect(result.success).toBe(false); expect(result.error).toBe(testError); }); - it('Should resolve with {success: true, stream: } on usermedia-permission-prompt when getUserMedia succeeds with audio tracks', function(done) { + it('Should resolve with {success: true, stream: } on promptUserMediaPermission when getUserMedia succeeds with audio tracks', function(done) { const fakeStream = { getAudioTracks: function() { return [{}]; } }; spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); @@ -158,13 +158,13 @@ function testUserMediaPermissions() { done(); }); - dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-prompt' }); + dispatchCustomMessageEvent({ action: 'event', type: 'promptUserMediaPermission' }); }); it('Should clear media_permission_pending_ after resolution', function(done) { spyOn(airconsole, 'sendEvent_'); - 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) { @@ -172,7 +172,7 @@ function testUserMediaPermissions() { done(); }); - dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-granted' }); + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionGranted' }); }); it('Should handle double-call to _resolveMediaPermission_ safely (second call is no-op)', function(done) { @@ -183,7 +183,7 @@ function testUserMediaPermissions() { resolveCallCount++; expect(resolveCallCount).toBe(1); expect(result.success).toBe(false); - expect(result.reason).toBe('denied-temporary'); + expect(result.reason).toBe('temporary'); // Now call _resolveMediaPermission_ again - this should be no-op airconsole._resolveMediaPermission_({ success: true }); @@ -193,7 +193,7 @@ function testUserMediaPermissions() { done(); }); - dispatchCustomMessageEvent({ action: 'event', type: 'usermedia-permission-denied', data: { denial: false } }); + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionDenied', data: { reason: AirConsole.MEDIA_PERMISSION_DENIED.temporary } }); }); // Group 3: Timeout