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

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