const EventTarget = require('./eventtarget');
const inherits = require('util').inherits;
const POLL_INTERVAL_MS = 500;
const nativeMediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices;
/**
* Make a custom MediaDevices object, and proxy through existing functionality. If
* devicechange is present, we simply reemit the event. If not, we will do the
* detection ourselves and fire the event when necessary. The same logic exists
* for deviceinfochange for consistency, however deviceinfochange is our own event
* so it is unlikely that it will ever be native. The w3c spec for devicechange
* is unclear as to whether MediaDeviceInfo changes (such as label) will
* trigger the devicechange event. We have an open question on this here:
* https://bugs.chromium.org/p/chromium/issues/detail?id=585096
*/
function MediaDevicesShim() {
EventTarget.call(this);
this._defineEventHandler('devicechange');
this._defineEventHandler('deviceinfochange');
const knownDevices = [];
Object.defineProperties(this, {
_deviceChangeIsNative: {
value: reemitNativeEvent(this, 'devicechange')
},
_deviceInfoChangeIsNative: {
value: reemitNativeEvent(this, 'deviceinfochange')
},
_knownDevices: {
value: knownDevices
},
_pollInterval: {
value: null,
writable: true
}
});
if (typeof nativeMediaDevices.enumerateDevices === 'function') {
nativeMediaDevices.enumerateDevices().then(devices => {
devices.sort(sortDevicesById).forEach([].push, knownDevices);
});
}
this._eventEmitter.on('newListener', function maybeStartPolling(eventName) {
if (eventName !== 'devicechange' && eventName !== 'deviceinfochange') {
return;
}
this._pollInterval = this._pollInterval
|| setInterval(sampleDevices.bind(null, this), POLL_INTERVAL_MS);
}.bind(this));
this._eventEmitter.on('removeListener', function maybeStopPolling() {
if (this._pollInterval && !hasChangeListeners(this)) {
clearInterval(this._pollInterval);
this._pollInterval = null;
}
}.bind(this));
}
inherits(MediaDevicesShim, EventTarget);
if (nativeMediaDevices && typeof nativeMediaDevices.enumerateDevices === 'function') {
MediaDevicesShim.prototype.enumerateDevices = function enumerateDevices() {
return nativeMediaDevices.enumerateDevices(...arguments);
};
}
MediaDevicesShim.prototype.getUserMedia = function getUserMedia() {
return nativeMediaDevices.getUserMedia(...arguments);
};
function deviceInfosHaveChanged(newDevices, oldDevices) {
const oldLabels = oldDevices.reduce((map, device) => map.set(device.deviceId, device.label || null), new Map());
return newDevices.some(newDevice => {
const oldLabel = oldLabels.get(newDevice.deviceId);
return typeof oldLabel !== 'undefined' && oldLabel !== newDevice.label;
});
}
function devicesHaveChanged(newDevices, oldDevices) {
return newDevices.length !== oldDevices.length
|| propertyHasChanged('deviceId', newDevices, oldDevices);
}
function hasChangeListeners(mediaDevices) {
return ['devicechange', 'deviceinfochange'].reduce((count, event) => count + mediaDevices._eventEmitter.listenerCount(event), 0) > 0;
}
/**
* Sample the current set of devices and emit devicechange event if a device has been
* added or removed, and deviceinfochange if a device's label has changed.
* @param {MediaDevicesShim} mediaDevices
* @private
*/
function sampleDevices(mediaDevices) {
nativeMediaDevices.enumerateDevices().then(newDevices => {
const knownDevices = mediaDevices._knownDevices;
const oldDevices = knownDevices.slice();
// Replace known devices in-place
[].splice.apply(knownDevices, [0, knownDevices.length]
.concat(newDevices.sort(sortDevicesById)));
if (!mediaDevices._deviceChangeIsNative
&& devicesHaveChanged(knownDevices, oldDevices)) {
mediaDevices.dispatchEvent(new Event('devicechange'));
}
if (!mediaDevices._deviceInfoChangeIsNative
&& deviceInfosHaveChanged(knownDevices, oldDevices)) {
mediaDevices.dispatchEvent(new Event('deviceinfochange'));
}
});
}
/**
* Accepts two sorted arrays and the name of a property to compare on objects from each.
* Arrays should also be of the same length.
* @param {string} propertyName - Name of the property to compare on each object
* @param {Array<Object>} as - The left-side array of objects to compare.
* @param {Array<Object>} bs - The right-side array of objects to compare.
* @private
* @returns {boolean} True if the property of any object in array A is different than
* the same property of its corresponding object in array B.
*/
function propertyHasChanged(propertyName, as, bs) {
return as.some((a, i) => a[propertyName] !== bs[i][propertyName]);
}
/**
* Re-emit the native event, if the native mediaDevices has the corresponding property.
* @param {MediaDevicesShim} mediaDevices
* @param {string} eventName - Name of the event
* @private
* @returns {boolean} Whether the native mediaDevice had the corresponding property
*/
function reemitNativeEvent(mediaDevices, eventName) {
const methodName = `on${eventName}`;
function dispatchEvent(event) {
mediaDevices.dispatchEvent(event);
}
if (methodName in nativeMediaDevices) {
// Use addEventListener if it's available so we don't stomp on any other listeners
// for this event. Currently, navigator.mediaDevices.addEventListener does not exist in Safari.
if ('addEventListener' in nativeMediaDevices) {
nativeMediaDevices.addEventListener(eventName, dispatchEvent);
} else {
nativeMediaDevices[methodName] = dispatchEvent;
}
return true;
}
return false;
}
function sortDevicesById(a, b) {
return a.deviceId < b.deviceId;
}
module.exports = (function shimMediaDevices() {
return nativeMediaDevices ? new MediaDevicesShim() : null;
})();