You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

middleware.web.ts 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { AnyAction } from 'redux';
  2. // @ts-expect-error
  3. import UIEvents from '../../../../service/UI/UIEvents';
  4. import { IStore } from '../../app/types';
  5. import { processExternalDeviceRequest } from '../../device-selection/functions';
  6. import { showNotification, showWarningNotification } from '../../notifications/actions';
  7. import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
  8. import { replaceAudioTrackById, replaceVideoTrackById, setDeviceStatusWarning } from '../../prejoin/actions';
  9. import { isPrejoinPageVisible } from '../../prejoin/functions';
  10. import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app/actionTypes';
  11. import { isMobileBrowser } from '../environment/utils';
  12. import JitsiMeetJS, { JitsiMediaDevicesEvents, JitsiTrackErrors } from '../lib-jitsi-meet';
  13. import { MEDIA_TYPE } from '../media/constants';
  14. import MiddlewareRegistry from '../redux/MiddlewareRegistry';
  15. import { updateSettings } from '../settings/actions';
  16. import { getLocalTrack } from '../tracks/functions';
  17. import {
  18. CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
  19. NOTIFY_CAMERA_ERROR,
  20. NOTIFY_MIC_ERROR,
  21. SET_AUDIO_INPUT_DEVICE,
  22. SET_VIDEO_INPUT_DEVICE,
  23. UPDATE_DEVICE_LIST
  24. } from './actionTypes';
  25. import {
  26. devicePermissionsChanged,
  27. removePendingDeviceRequests,
  28. setAudioInputDevice,
  29. setVideoInputDevice
  30. } from './actions';
  31. import {
  32. areDeviceLabelsInitialized,
  33. formatDeviceLabel,
  34. logDevices,
  35. setAudioOutputDeviceId
  36. } from './functions';
  37. import logger from './logger';
  38. const JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP = {
  39. microphone: {
  40. [JitsiTrackErrors.CONSTRAINT_FAILED]: 'dialog.micConstraintFailedError',
  41. [JitsiTrackErrors.GENERAL]: 'dialog.micUnknownError',
  42. [JitsiTrackErrors.NOT_FOUND]: 'dialog.micNotFoundError',
  43. [JitsiTrackErrors.PERMISSION_DENIED]: 'dialog.micPermissionDeniedError',
  44. [JitsiTrackErrors.TIMEOUT]: 'dialog.micTimeoutError'
  45. },
  46. camera: {
  47. [JitsiTrackErrors.CONSTRAINT_FAILED]: 'dialog.cameraConstraintFailedError',
  48. [JitsiTrackErrors.GENERAL]: 'dialog.cameraUnknownError',
  49. [JitsiTrackErrors.NOT_FOUND]: 'dialog.cameraNotFoundError',
  50. [JitsiTrackErrors.PERMISSION_DENIED]: 'dialog.cameraPermissionDeniedError',
  51. [JitsiTrackErrors.UNSUPPORTED_RESOLUTION]: 'dialog.cameraUnsupportedResolutionError',
  52. [JitsiTrackErrors.TIMEOUT]: 'dialog.cameraTimeoutError'
  53. }
  54. };
  55. /**
  56. * A listener for device permissions changed reported from lib-jitsi-meet.
  57. */
  58. let permissionsListener: Function | undefined;
  59. /**
  60. * Implements the middleware of the feature base/devices.
  61. *
  62. * @param {Store} store - Redux store.
  63. * @returns {Function}
  64. */
  65. // eslint-disable-next-line no-unused-vars
  66. MiddlewareRegistry.register(store => next => action => {
  67. switch (action.type) {
  68. case APP_WILL_MOUNT: {
  69. const _permissionsListener = (permissions: Object) => {
  70. store.dispatch(devicePermissionsChanged(permissions));
  71. };
  72. const { mediaDevices } = JitsiMeetJS;
  73. permissionsListener = _permissionsListener;
  74. mediaDevices.addEventListener(JitsiMediaDevicesEvents.PERMISSIONS_CHANGED, permissionsListener);
  75. Promise.all([
  76. mediaDevices.isDevicePermissionGranted('audio'),
  77. mediaDevices.isDevicePermissionGranted('video')
  78. ])
  79. .then(results => {
  80. _permissionsListener({
  81. audio: results[0],
  82. video: results[1]
  83. });
  84. })
  85. .catch(() => {
  86. // Ignore errors.
  87. });
  88. break;
  89. }
  90. case APP_WILL_UNMOUNT:
  91. if (typeof permissionsListener === 'function') {
  92. JitsiMeetJS.mediaDevices.removeEventListener(
  93. JitsiMediaDevicesEvents.PERMISSIONS_CHANGED, permissionsListener);
  94. permissionsListener = undefined;
  95. }
  96. break;
  97. case NOTIFY_CAMERA_ERROR: {
  98. if (!action.error) {
  99. break;
  100. }
  101. const { message, name } = action.error;
  102. const cameraJitsiTrackErrorMsg
  103. = JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[name];
  104. const cameraErrorMsg = cameraJitsiTrackErrorMsg
  105. || JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP
  106. .camera[JitsiTrackErrors.GENERAL];
  107. const additionalCameraErrorMsg = cameraJitsiTrackErrorMsg ? null : message;
  108. const titleKey = name === JitsiTrackErrors.PERMISSION_DENIED
  109. ? 'deviceError.cameraPermission' : 'deviceError.cameraError';
  110. store.dispatch(showWarningNotification({
  111. description: additionalCameraErrorMsg,
  112. descriptionKey: cameraErrorMsg,
  113. titleKey
  114. }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  115. if (isPrejoinPageVisible(store.getState())) {
  116. store.dispatch(setDeviceStatusWarning(titleKey));
  117. }
  118. break;
  119. }
  120. case NOTIFY_MIC_ERROR: {
  121. if (!action.error) {
  122. break;
  123. }
  124. const { message, name } = action.error;
  125. const micJitsiTrackErrorMsg
  126. = JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[name];
  127. const micErrorMsg = micJitsiTrackErrorMsg
  128. || JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP
  129. .microphone[JitsiTrackErrors.GENERAL];
  130. const additionalMicErrorMsg = micJitsiTrackErrorMsg ? null : message;
  131. const titleKey = name === JitsiTrackErrors.PERMISSION_DENIED
  132. ? 'deviceError.microphonePermission'
  133. : 'deviceError.microphoneError';
  134. store.dispatch(showWarningNotification({
  135. description: additionalMicErrorMsg,
  136. descriptionKey: micErrorMsg,
  137. titleKey
  138. }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  139. if (isPrejoinPageVisible(store.getState())) {
  140. store.dispatch(setDeviceStatusWarning(titleKey));
  141. }
  142. break;
  143. }
  144. case SET_AUDIO_INPUT_DEVICE:
  145. if (isPrejoinPageVisible(store.getState())) {
  146. store.dispatch(replaceAudioTrackById(action.deviceId));
  147. } else {
  148. APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId);
  149. }
  150. break;
  151. case SET_VIDEO_INPUT_DEVICE: {
  152. const localTrack = getLocalTrack(store.getState()['features/base/tracks'], MEDIA_TYPE.VIDEO);
  153. // on mobile devices the video stream has to be stopped before replacing it
  154. if (isMobileBrowser() && localTrack && !localTrack.muted) {
  155. localTrack.jitsiTrack.stopStream();
  156. }
  157. if (isPrejoinPageVisible(store.getState())) {
  158. store.dispatch(replaceVideoTrackById(action.deviceId));
  159. } else {
  160. APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId);
  161. }
  162. break;
  163. }
  164. case UPDATE_DEVICE_LIST:
  165. logDevices(action.devices, 'Device list updated');
  166. if (areDeviceLabelsInitialized(store.getState())) {
  167. return _processPendingRequests(store, next, action);
  168. }
  169. break;
  170. case CHECK_AND_NOTIFY_FOR_NEW_DEVICE:
  171. _checkAndNotifyForNewDevice(store, action.newDevices, action.oldDevices);
  172. break;
  173. }
  174. return next(action);
  175. });
  176. /**
  177. * Does extra sync up on properties that may need to be updated after the
  178. * conference was joined.
  179. *
  180. * @param {Store} store - The redux store in which the specified {@code action}
  181. * is being dispatched.
  182. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  183. * specified {@code action} to the specified {@code store}.
  184. * @param {Action} action - The redux action {@code CONFERENCE_JOINED} which is
  185. * being dispatched in the specified {@code store}.
  186. * @private
  187. * @returns {Object} The value returned by {@code next(action)}.
  188. */
  189. function _processPendingRequests({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
  190. const result = next(action);
  191. const state = getState();
  192. const { pendingRequests } = state['features/base/devices'];
  193. if (!pendingRequests || pendingRequests.length === 0) {
  194. return result;
  195. }
  196. pendingRequests.forEach((request: any) => {
  197. processExternalDeviceRequest(
  198. dispatch,
  199. getState,
  200. request,
  201. request.responseCallback);
  202. });
  203. dispatch(removePendingDeviceRequests());
  204. return result;
  205. }
  206. /**
  207. * Finds a new device by comparing new and old array of devices and dispatches
  208. * notification with the new device. For new devices with same groupId only one
  209. * notification will be shown, this is so to avoid showing multiple notifications
  210. * for audio input and audio output devices.
  211. *
  212. * @param {Store} store - The redux store in which the specified {@code action}
  213. * is being dispatched.
  214. * @param {MediaDeviceInfo[]} newDevices - The array of new devices we received.
  215. * @param {MediaDeviceInfo[]} oldDevices - The array of the old devices we have.
  216. * @private
  217. * @returns {void}
  218. */
  219. function _checkAndNotifyForNewDevice(store: IStore, newDevices: MediaDeviceInfo[], oldDevices: MediaDeviceInfo[]) {
  220. const { dispatch } = store;
  221. // let's intersect both newDevices and oldDevices and handle thew newly
  222. // added devices
  223. const onlyNewDevices = newDevices.filter(
  224. nDevice => !oldDevices.find(
  225. device => device.deviceId === nDevice.deviceId));
  226. // we group devices by groupID which normally is the grouping by physical device
  227. // plugging in headset we provide normally two device, one input and one output
  228. // and we want to show only one notification for this physical audio device
  229. const devicesGroupBy: {
  230. [key: string]: MediaDeviceInfo[];
  231. } = onlyNewDevices.reduce((accumulated: any, value) => {
  232. accumulated[value.groupId] = accumulated[value.groupId] || [];
  233. accumulated[value.groupId].push(value);
  234. return accumulated;
  235. }, {});
  236. Object.values(devicesGroupBy).forEach(devicesArray => {
  237. if (devicesArray.length < 1) {
  238. return;
  239. }
  240. // let's get the first device as a reference, we will use it for
  241. // label and type
  242. const newDevice = devicesArray[0];
  243. // we want to strip any device details that are not very
  244. // user friendly, like usb ids put in brackets at the end
  245. const description = formatDeviceLabel(newDevice.label);
  246. let titleKey;
  247. switch (newDevice.kind) {
  248. case 'videoinput': {
  249. titleKey = 'notify.newDeviceCameraTitle';
  250. break;
  251. }
  252. case 'audioinput' :
  253. case 'audiooutput': {
  254. titleKey = 'notify.newDeviceAudioTitle';
  255. break;
  256. }
  257. }
  258. if (!isPrejoinPageVisible(store.getState())) {
  259. dispatch(showNotification({
  260. description,
  261. titleKey,
  262. customActionNameKey: [ 'notify.newDeviceAction' ],
  263. customActionHandler: [ _useDevice.bind(undefined, store, devicesArray) ]
  264. }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  265. }
  266. });
  267. }
  268. /**
  269. * Set a device to be currently used, selected by the user.
  270. *
  271. * @param {Store} store - The redux store in which the specified {@code action}
  272. * is being dispatched.
  273. * @param {Array<MediaDeviceInfo|InputDeviceInfo>} devices - The devices to save.
  274. * @returns {boolean} - Returns true in order notifications to be dismissed.
  275. * @private
  276. */
  277. function _useDevice({ dispatch }: IStore, devices: MediaDeviceInfo[]) {
  278. devices.forEach(device => {
  279. switch (device.kind) {
  280. case 'videoinput': {
  281. dispatch(updateSettings({
  282. userSelectedCameraDeviceId: device.deviceId,
  283. userSelectedCameraDeviceLabel: device.label
  284. }));
  285. dispatch(setVideoInputDevice(device.deviceId));
  286. break;
  287. }
  288. case 'audioinput': {
  289. dispatch(updateSettings({
  290. userSelectedMicDeviceId: device.deviceId,
  291. userSelectedMicDeviceLabel: device.label
  292. }));
  293. dispatch(setAudioInputDevice(device.deviceId));
  294. break;
  295. }
  296. case 'audiooutput': {
  297. setAudioOutputDeviceId(
  298. device.deviceId,
  299. dispatch,
  300. true,
  301. device.label)
  302. .then(() => logger.log('changed audio output device'))
  303. .catch(err => {
  304. logger.warn(
  305. 'Failed to change audio output device.',
  306. 'Default or previously set audio output device will',
  307. ' be used instead.',
  308. err);
  309. });
  310. break;
  311. }
  312. }
  313. });
  314. return true;
  315. }