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.ts 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import { createRecordingEvent } from '../analytics/AnalyticsEvents';
  2. import { sendAnalytics } from '../analytics/functions';
  3. import { IStore } from '../app/types';
  4. import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
  5. import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
  6. import { getCurrentConference } from '../base/conference/functions';
  7. import JitsiMeetJS, {
  8. JitsiConferenceEvents,
  9. JitsiRecordingConstants
  10. } from '../base/lib-jitsi-meet';
  11. import { MEDIA_TYPE } from '../base/media/constants';
  12. import { updateLocalRecordingStatus } from '../base/participants/actions';
  13. import { getParticipantDisplayName } from '../base/participants/functions';
  14. import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
  15. import StateListenerRegistry from '../base/redux/StateListenerRegistry';
  16. import {
  17. playSound,
  18. stopSound
  19. } from '../base/sounds/actions';
  20. import { TRACK_ADDED } from '../base/tracks/actionTypes';
  21. import { showErrorNotification, showNotification } from '../notifications/actions';
  22. import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
  23. import { RECORDING_SESSION_UPDATED, START_LOCAL_RECORDING, STOP_LOCAL_RECORDING } from './actionTypes';
  24. import {
  25. clearRecordingSessions,
  26. hidePendingRecordingNotification,
  27. showPendingRecordingNotification,
  28. showRecordingError,
  29. showRecordingLimitNotification,
  30. showRecordingWarning,
  31. showStartedRecordingNotification,
  32. showStoppedRecordingNotification,
  33. updateRecordingSessionData
  34. } from './actions';
  35. import LocalRecordingManager from './components/Recording/LocalRecordingManager';
  36. import {
  37. LIVE_STREAMING_OFF_SOUND_ID,
  38. LIVE_STREAMING_ON_SOUND_ID,
  39. RECORDING_OFF_SOUND_ID,
  40. RECORDING_ON_SOUND_ID
  41. } from './constants';
  42. import {
  43. getResourceId,
  44. getSessionById,
  45. registerRecordingAudioFiles,
  46. unregisterRecordingAudioFiles
  47. } from './functions';
  48. import logger from './logger';
  49. /**
  50. * StateListenerRegistry provides a reliable way to detect the leaving of a
  51. * conference, where we need to clean up the recording sessions.
  52. */
  53. StateListenerRegistry.register(
  54. /* selector */ state => getCurrentConference(state),
  55. /* listener */ (conference, { dispatch }) => {
  56. if (!conference) {
  57. dispatch(clearRecordingSessions());
  58. }
  59. }
  60. );
  61. /**
  62. * The redux middleware to handle the recorder updates in a React way.
  63. *
  64. * @param {Store} store - The redux store.
  65. * @returns {Function}
  66. */
  67. MiddlewareRegistry.register(({ dispatch, getState }) => next => async action => {
  68. let oldSessionData;
  69. if (action.type === RECORDING_SESSION_UPDATED) {
  70. oldSessionData
  71. = getSessionById(getState(), action.sessionData.id);
  72. }
  73. const result = next(action);
  74. switch (action.type) {
  75. case APP_WILL_MOUNT:
  76. registerRecordingAudioFiles(dispatch);
  77. break;
  78. case APP_WILL_UNMOUNT:
  79. unregisterRecordingAudioFiles(dispatch);
  80. break;
  81. case CONFERENCE_JOIN_IN_PROGRESS: {
  82. const { conference } = action;
  83. conference.on(
  84. JitsiConferenceEvents.RECORDER_STATE_CHANGED,
  85. (recorderSession: any) => {
  86. if (recorderSession) {
  87. recorderSession.getID() && dispatch(updateRecordingSessionData(recorderSession));
  88. recorderSession.getError() && _showRecordingErrorNotification(recorderSession, dispatch);
  89. }
  90. return;
  91. });
  92. break;
  93. }
  94. case START_LOCAL_RECORDING: {
  95. const { localRecording } = getState()['features/base/config'];
  96. const { onlySelf } = action;
  97. try {
  98. await LocalRecordingManager.startLocalRecording({ dispatch,
  99. getState }, action.onlySelf);
  100. const props = {
  101. descriptionKey: 'recording.on',
  102. titleKey: 'dialog.recording'
  103. };
  104. if (localRecording?.notifyAllParticipants && !onlySelf) {
  105. dispatch(playSound(RECORDING_ON_SOUND_ID));
  106. }
  107. dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  108. dispatch(showNotification({
  109. titleKey: 'recording.localRecordingStartWarningTitle',
  110. descriptionKey: 'recording.localRecordingStartWarning'
  111. }, NOTIFICATION_TIMEOUT_TYPE.STICKY));
  112. dispatch(updateLocalRecordingStatus(true, onlySelf));
  113. sendAnalytics(createRecordingEvent('started', `local${onlySelf ? '.self' : ''}`));
  114. if (typeof APP !== 'undefined') {
  115. APP.API.notifyRecordingStatusChanged(true, 'local');
  116. }
  117. } catch (err: any) {
  118. logger.error('Capture failed', err);
  119. let descriptionKey = 'recording.error';
  120. if (err.message === 'WrongSurfaceSelected') {
  121. descriptionKey = 'recording.surfaceError';
  122. } else if (err.message === 'NoLocalStreams') {
  123. descriptionKey = 'recording.noStreams';
  124. } else if (err.message === 'NoMicTrack') {
  125. descriptionKey = 'recording.noMicPermission';
  126. }
  127. const props = {
  128. descriptionKey,
  129. titleKey: 'recording.failedToStart'
  130. };
  131. if (typeof APP !== 'undefined') {
  132. APP.API.notifyRecordingStatusChanged(false, 'local', err.message);
  133. }
  134. dispatch(showErrorNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  135. }
  136. break;
  137. }
  138. case STOP_LOCAL_RECORDING: {
  139. const { localRecording } = getState()['features/base/config'];
  140. if (LocalRecordingManager.isRecordingLocally()) {
  141. LocalRecordingManager.stopLocalRecording();
  142. dispatch(updateLocalRecordingStatus(false));
  143. if (localRecording?.notifyAllParticipants && !LocalRecordingManager.selfRecording) {
  144. dispatch(playSound(RECORDING_OFF_SOUND_ID));
  145. }
  146. if (typeof APP !== 'undefined') {
  147. APP.API.notifyRecordingStatusChanged(false, 'local');
  148. }
  149. }
  150. break;
  151. }
  152. case RECORDING_SESSION_UPDATED: {
  153. // When in recorder mode no notifications are shown
  154. // or extra sounds are also not desired
  155. // but we want to indicate those in case of sip gateway
  156. const {
  157. iAmRecorder,
  158. iAmSipGateway,
  159. recordingLimit
  160. } = getState()['features/base/config'];
  161. if (iAmRecorder && !iAmSipGateway) {
  162. break;
  163. }
  164. const updatedSessionData
  165. = getSessionById(getState(), action.sessionData.id);
  166. const { initiator, mode = '', terminator } = updatedSessionData ?? {};
  167. const { PENDING, OFF, ON } = JitsiRecordingConstants.status;
  168. if (updatedSessionData?.status === PENDING
  169. && (!oldSessionData || oldSessionData.status !== PENDING)) {
  170. dispatch(showPendingRecordingNotification(mode));
  171. } else if (updatedSessionData?.status !== PENDING) {
  172. dispatch(hidePendingRecordingNotification(mode));
  173. if (updatedSessionData?.status === ON) {
  174. // We receive 2 updates of the session status ON. The first one is from jibri when it joins.
  175. // The second one is from jicofo which will deliever the initiator value. Since the start
  176. // recording notification uses the initiator value we skip the jibri update and show the
  177. // notification on the update from jicofo.
  178. // FIXE: simplify checks when the backend start sending only one status ON update containing the
  179. // initiator.
  180. if (initiator && !oldSessionData?.initiator) {
  181. if (typeof recordingLimit === 'object') {
  182. dispatch(showRecordingLimitNotification(mode));
  183. } else {
  184. dispatch(showStartedRecordingNotification(mode, initiator, action.sessionData.id));
  185. }
  186. }
  187. if (!oldSessionData || oldSessionData.status !== ON) {
  188. sendAnalytics(createRecordingEvent('start', mode));
  189. let soundID;
  190. if (mode === JitsiRecordingConstants.mode.FILE) {
  191. soundID = RECORDING_ON_SOUND_ID;
  192. } else if (mode === JitsiRecordingConstants.mode.STREAM) {
  193. soundID = LIVE_STREAMING_ON_SOUND_ID;
  194. }
  195. if (soundID) {
  196. dispatch(playSound(soundID));
  197. }
  198. if (typeof APP !== 'undefined') {
  199. APP.API.notifyRecordingStatusChanged(true, mode);
  200. }
  201. }
  202. } else if (updatedSessionData?.status === OFF
  203. && (!oldSessionData || oldSessionData.status !== OFF)) {
  204. if (terminator) {
  205. dispatch(
  206. showStoppedRecordingNotification(
  207. mode, getParticipantDisplayName(getState, getResourceId(terminator))));
  208. }
  209. let duration = 0, soundOff, soundOn;
  210. if (oldSessionData?.timestamp) {
  211. duration
  212. = (Date.now() / 1000) - oldSessionData.timestamp;
  213. }
  214. sendAnalytics(createRecordingEvent('stop', mode, duration));
  215. if (mode === JitsiRecordingConstants.mode.FILE) {
  216. soundOff = RECORDING_OFF_SOUND_ID;
  217. soundOn = RECORDING_ON_SOUND_ID;
  218. } else if (mode === JitsiRecordingConstants.mode.STREAM) {
  219. soundOff = LIVE_STREAMING_OFF_SOUND_ID;
  220. soundOn = LIVE_STREAMING_ON_SOUND_ID;
  221. }
  222. if (soundOff && soundOn) {
  223. dispatch(stopSound(soundOn));
  224. dispatch(playSound(soundOff));
  225. }
  226. if (typeof APP !== 'undefined') {
  227. APP.API.notifyRecordingStatusChanged(false, mode);
  228. }
  229. }
  230. }
  231. break;
  232. }
  233. case TRACK_ADDED: {
  234. const { track } = action;
  235. if (LocalRecordingManager.isRecordingLocally() && track.mediaType === MEDIA_TYPE.AUDIO) {
  236. const audioTrack = track.jitsiTrack.track;
  237. LocalRecordingManager.addAudioTrackToLocalRecording(audioTrack);
  238. }
  239. break;
  240. }
  241. }
  242. return result;
  243. });
  244. /**
  245. * Shows a notification about an error in the recording session. A
  246. * default notification will display if no error is specified in the passed
  247. * in recording session.
  248. *
  249. * @private
  250. * @param {Object} recorderSession - The recorder session model from the
  251. * lib.
  252. * @param {Dispatch} dispatch - The Redux Dispatch function.
  253. * @returns {void}
  254. */
  255. function _showRecordingErrorNotification(recorderSession: any, dispatch: IStore['dispatch']) {
  256. const mode = recorderSession.getMode();
  257. const error = recorderSession.getError();
  258. const isStreamMode = mode === JitsiMeetJS.constants.recording.mode.STREAM;
  259. switch (error) {
  260. case JitsiMeetJS.constants.recording.error.SERVICE_UNAVAILABLE:
  261. dispatch(showRecordingError({
  262. descriptionKey: 'recording.unavailable',
  263. descriptionArguments: {
  264. serviceName: isStreamMode
  265. ? '$t(liveStreaming.serviceName)'
  266. : '$t(recording.serviceName)'
  267. },
  268. titleKey: isStreamMode
  269. ? 'liveStreaming.unavailableTitle'
  270. : 'recording.unavailableTitle'
  271. }));
  272. break;
  273. case JitsiMeetJS.constants.recording.error.RESOURCE_CONSTRAINT:
  274. dispatch(showRecordingError({
  275. descriptionKey: isStreamMode
  276. ? 'liveStreaming.busy'
  277. : 'recording.busy',
  278. titleKey: isStreamMode
  279. ? 'liveStreaming.busyTitle'
  280. : 'recording.busyTitle'
  281. }));
  282. break;
  283. case JitsiMeetJS.constants.recording.error.UNEXPECTED_REQUEST:
  284. dispatch(showRecordingWarning({
  285. descriptionKey: isStreamMode
  286. ? 'liveStreaming.sessionAlreadyActive'
  287. : 'recording.sessionAlreadyActive',
  288. titleKey: isStreamMode ? 'liveStreaming.inProgress' : 'recording.inProgress'
  289. }));
  290. break;
  291. default:
  292. dispatch(showRecordingError({
  293. descriptionKey: isStreamMode
  294. ? 'liveStreaming.error'
  295. : 'recording.error',
  296. titleKey: isStreamMode
  297. ? 'liveStreaming.failedToStart'
  298. : 'recording.failedToStart'
  299. }));
  300. break;
  301. }
  302. if (typeof APP !== 'undefined') {
  303. APP.API.notifyRecordingStatusChanged(false, mode, error);
  304. }
  305. }