diff --git a/beta/airconsole-1.11.0.js b/beta/airconsole-1.11.0.js index efd178c..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 * @@ -691,6 +699,98 @@ AirConsole.prototype.vibrate = function(options) { this.set_("vibrate", options); }; + +/** ------------------------------------------------------------------------ * + * @chapter MICROPHONE PERMISSION * + * ------------------------------------------------------------------------- */ + +/** + * 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.onUserMediaAccessGranted = function(device_id) {}; + +/** + * @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.onUserMediaAccessDenied = 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 {Object.} constraints - User Media Request constraints + * Currently only 'audio' is supported. + * @return {Promise.<{success: boolean, stream: MediaStream=, error: Error=}>} + */ +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('getUserMedia failed: getUserMedia is not supported on screen') }); + return; + } + if (me.device_id === undefined || me.device_id === null) { + resolve({ success: false, error: new Error('getUserMedia failed: AirConsole not ready') }); + return; + } + if (me.media_permission_pending_) { + resolve({ success: false, error: new Error('getUserMedia failed: Request already in progress') }); + return; + } + 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_constraints_ = constraints; + 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); + + // Send the request to the platform to decide where and how the user media request needs to take place based on + // browser or controller environment. + me.sendEvent_('requestUserMediaPermission', { constraints: constraints }); + }); +}; + +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; + if (resolve) { + resolve(result); + } +}; + /** ------------------------------------------------------------------------ * * @chapter ADS * * ------------------------------------------------------------------------- */ @@ -1355,6 +1455,16 @@ 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) { + const { granted, reason } = data.device_data.userMediaPermission; + if (granted) { + me.onUserMediaAccessGranted(sender); + } else { + me.onUserMediaAccessDenied(sender, reason); + } + } + } } } } else if (data.action === "ready") { @@ -1453,6 +1563,40 @@ AirConsole.prototype.onPostMessage_ = function(event) { } } else if (data.action === 'setGameSafeArea') { me.onSetSafeArea(data.gameSafeArea); + } else if (data.action === 'event') { + const { type } = data; + + if (type === 'userMediaPermissionDenied') { + const reason = data.data?.reason; + + me._resolveMediaPermission_({ + success: false, + 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.sendEvent_('userMediaPermissionGranted', {}); + }, + function failure(error) { + // Native controller + if (type === 'userMediaPermissionGranted') { + me._resolveMediaPermission_({ success: false, error }); + } else if (type === 'promptUserMediaPermission') { + me.resolveMediaPermissionError_ = error; + // Web based controller + const userPromptDuration = performance.now() - userPromptStartTime; + if (error.name === 'NotAllowedError') { + me.sendEvent_('userMediaPermissionDenied', { userPromptDuration }); + } + } + } + ); + } } }; @@ -1526,6 +1670,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_ = function(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/airconsole-1.11.0-spec.html b/tests/airconsole-1.11.0-spec.html index a51dd6a..501f410 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..88f3711 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("User Media Permissions", function () { + afterEach(function () { + tearDown(); + }); + + testUserMediaPermissions(); + }); }); diff --git a/tests/spec/methods/spec-usermedia-permissions.js b/tests/spec/methods/spec-usermedia-permissions.js new file mode 100644 index 0000000..abbaaf1 --- /dev/null +++ b/tests/spec/methods/spec-usermedia-permissions.js @@ -0,0 +1,212 @@ +function testUserMediaPermissions() { + function initAirConsoleAsController() { + spyOn(document, 'getElementsByTagName').and.callFake(function() { + return [{ src: 'http://localhost/api/airconsole-latest.js' }]; + }); + airconsole = new AirConsole({ setup_document: false }); + airconsole.device_id = DEVICE_ID; // 2 = controller + airconsole.devices[0] = {}; + airconsole.devices[DEVICE_ID] = { uid: 1237, nicktype: 'Sergio', location: LOCATION, custom: {} }; + } + + 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) { + 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'); + done(); + }); + }); + + it('Should reject with "AirConsole not ready" when device_id === undefined', function(done) { + 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'); + done(); + }); + }); + + it('Should reject with "AirConsole not ready" when device_id === null', function(done) { + 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'); + done(); + }); + }); + + it('Should reject with "Request already in progress" when media_permission_pending_ is already true', function(done) { + 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'); + done(); + }); + }); + + it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are null', function(done) { + + 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'); + done(); + }); + }); + + it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are undefined', function(done) { + + 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'); + done(); + }); + }); + + it('Should reject with "getUserMedia failed: audio or video constraint must be specified" when constraints are empty', function(done) { + + airconsole.getUserMedia({}).then(function(result) { + expect(result.success).toBe(false); + 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) { + 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) { + spyOn(airconsole, 'sendEvent_'); + + airconsole.getUserMedia({audio: true}); + + expect(airconsole.sendEvent_).toHaveBeenCalledWith( + 'requestUserMediaPermission', + jasmine.objectContaining({ constraints: { audio: true } }) + ); + done(); + }); + + // Group 2: _resolveMediaPermission_ / event handler responses + + 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('temporary'); + done(); + }); + + dispatchCustomMessageEvent({ action: 'event', type: 'userMediaPermissionDenied', data: { reason: AirConsole.MEDIA_PERMISSION_DENIED.temporary } }); + }); + + it('Should resolve with {success: true, stream: } on userMediaPermissionGranted when getUserMedia succeeds with audio tracks', async function() { + + const fakeStream = { + getAudioTracks: function() { + return [{}]; + } + }; + spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(fakeStream)); + 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 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: '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 promptUserMediaPermission 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: 'promptUserMediaPermission' }); + }); + + it('Should clear media_permission_pending_ after resolution', function(done) { + spyOn(airconsole, 'sendEvent_'); + + const 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: 'userMediaPermissionGranted' }); + }); + + 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('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: 'userMediaPermissionDenied', data: { reason: AirConsole.MEDIA_PERMISSION_DENIED.temporary } }); + }); + + // Group 3: Timeout + + it('Should resolve with {success: false, error: {message: "timeout"}} after 30000ms', function(done) { + spyOn(airconsole, 'sendEvent_'); + + airconsole.getUserMedia({audio:true}).then(function(result) { + expect(result.success).toBe(false); + expect(result.error.message).toBe('timeout'); + done(); + }); + + jasmine.clock().tick(30001); + }); +}