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.

RecordingController.js 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. /* @flow */
  2. import { i18next } from '../../base/i18n';
  3. import {
  4. FlacAdapter,
  5. OggAdapter,
  6. WavAdapter
  7. } from '../recording';
  8. const logger = require('jitsi-meet-logger').getLogger(__filename);
  9. /**
  10. * XMPP command for signaling the start of local recording to all clients.
  11. * Should be sent by the moderator only.
  12. */
  13. const COMMAND_START = 'localRecStart';
  14. /**
  15. * XMPP command for signaling the stop of local recording to all clients.
  16. * Should be sent by the moderator only.
  17. */
  18. const COMMAND_STOP = 'localRecStop';
  19. /**
  20. * Participant property key for local recording stats.
  21. */
  22. const PROPERTY_STATS = 'localRecStats';
  23. /**
  24. * Default recording format.
  25. */
  26. const DEFAULT_RECORDING_FORMAT = 'flac';
  27. /**
  28. * States of the {@code RecordingController}.
  29. */
  30. const ControllerState = Object.freeze({
  31. /**
  32. * Idle (not recording).
  33. */
  34. IDLE: Symbol('IDLE'),
  35. /**
  36. * Engaged (recording).
  37. */
  38. RECORDING: Symbol('RECORDING')
  39. });
  40. /**
  41. * Type of the stats reported by each participant (client).
  42. */
  43. type RecordingStats = {
  44. /**
  45. * Current local recording session token used by the participant.
  46. */
  47. currentSessionToken: number,
  48. /**
  49. * Whether local recording is engaged on the participant's device.
  50. */
  51. isRecording: boolean,
  52. /**
  53. * Total recorded bytes. (Reserved for future use.)
  54. */
  55. recordedBytes: number,
  56. /**
  57. * Total recording duration. (Reserved for future use.)
  58. */
  59. recordedLength: number
  60. }
  61. /**
  62. * The component responsible for the coordination of local recording, across
  63. * multiple participants.
  64. * Current implementation requires that there is only one moderator in a room.
  65. */
  66. class RecordingController {
  67. /**
  68. * For each recording session, there is a separate @{code RecordingAdapter}
  69. * instance so that encoded bits from the previous sessions can still be
  70. * retrieved after they ended.
  71. *
  72. * @private
  73. */
  74. _adapters = {};
  75. /**
  76. * The {@code JitsiConference} instance.
  77. *
  78. * @private
  79. */
  80. _conference: * = null;
  81. /**
  82. * Current recording session token.
  83. * Session token is a number generated by the moderator, to ensure every
  84. * client is in the same recording state.
  85. *
  86. * @private
  87. */
  88. _currentSessionToken: number = -1;
  89. /**
  90. * Current state of {@code RecordingController}.
  91. *
  92. * @private
  93. */
  94. _state = ControllerState.IDLE;
  95. /**
  96. * Current recording format. This will be in effect from the next
  97. * recording session, i.e., if this value is changed during an on-going
  98. * recording session, that on-going session will not use the new format.
  99. *
  100. * @private
  101. */
  102. _format = DEFAULT_RECORDING_FORMAT;
  103. /**
  104. * Whether or not the {@code RecordingController} has registered for
  105. * XMPP events. Prevents initialization from happening multiple times.
  106. *
  107. * @private
  108. */
  109. _registered = false;
  110. /**
  111. * FIXME: callback function for the {@code RecordingController} to notify
  112. * UI it wants to display a notice. Keeps {@code RecordingController}
  113. * decoupled from UI.
  114. */
  115. onNotify: ?(string) => void;
  116. /**
  117. * FIXME: callback function for the {@code RecordingController} to notify
  118. * UI it wants to display a warning. Keeps {@code RecordingController}
  119. * decoupled from UI.
  120. */
  121. onWarning: ?(string) => void;
  122. /**
  123. * FIXME: callback function for the {@code RecordingController} to notify
  124. * UI that the local recording state has changed.
  125. */
  126. onStateChanged: ?(boolean) => void;
  127. /**
  128. * Constructor.
  129. *
  130. * @returns {void}
  131. */
  132. constructor() {
  133. this._updateStats = this._updateStats.bind(this);
  134. this._onStartCommand = this._onStartCommand.bind(this);
  135. this._onStopCommand = this._onStopCommand.bind(this);
  136. this._doStartRecording = this._doStartRecording.bind(this);
  137. this._doStopRecording = this._doStopRecording.bind(this);
  138. this.registerEvents = this.registerEvents.bind(this);
  139. this.getParticipantsStats = this.getParticipantsStats.bind(this);
  140. }
  141. registerEvents: () => void;
  142. /**
  143. * Registers listeners for XMPP events.
  144. *
  145. * @param {JitsiConference} conference - {@code JitsiConference} instance.
  146. * @returns {void}
  147. */
  148. registerEvents(conference: Object) {
  149. if (!this._registered) {
  150. this._conference = conference;
  151. if (this._conference) {
  152. this._conference
  153. .addCommandListener(COMMAND_STOP, this._onStopCommand);
  154. this._conference
  155. .addCommandListener(COMMAND_START, this._onStartCommand);
  156. this._registered = true;
  157. }
  158. }
  159. }
  160. /**
  161. * Signals the participants to start local recording.
  162. *
  163. * @returns {void}
  164. */
  165. startRecording() {
  166. this.registerEvents();
  167. if (this._conference && this._conference.isModerator()) {
  168. this._conference.removeCommand(COMMAND_STOP);
  169. this._conference.sendCommand(COMMAND_START, {
  170. attributes: {
  171. sessionToken: this._getRandomToken(),
  172. format: this._format
  173. }
  174. });
  175. } else {
  176. const message = i18next.t('localRecording.messages.notModerator');
  177. if (this.onWarning) {
  178. this.onWarning(message);
  179. }
  180. }
  181. }
  182. /**
  183. * Signals the participants to stop local recording.
  184. *
  185. * @returns {void}
  186. */
  187. stopRecording() {
  188. if (this._conference) {
  189. if (this._conference.isModerator) {
  190. this._conference.removeCommand(COMMAND_START);
  191. this._conference.sendCommand(COMMAND_STOP, {
  192. attributes: {
  193. sessionToken: this._currentSessionToken
  194. }
  195. });
  196. } else {
  197. const message
  198. = i18next.t('localRecording.messages.notModerator');
  199. if (this.onWarning) {
  200. this.onWarning(message);
  201. }
  202. }
  203. }
  204. }
  205. /**
  206. * Triggers the download of recorded data.
  207. * Browser only.
  208. *
  209. * @param {number} sessionToken - The token of the session to download.
  210. * @returns {void}
  211. */
  212. downloadRecordedData(sessionToken: number) {
  213. if (this._adapters[sessionToken]) {
  214. this._adapters[sessionToken].download();
  215. } else {
  216. logger.error(`Invalid session token for download ${sessionToken}`);
  217. }
  218. }
  219. /**
  220. * Switches the recording format.
  221. *
  222. * @param {string} newFormat - The new format.
  223. * @returns {void}
  224. */
  225. switchFormat(newFormat: string) {
  226. this._format = newFormat;
  227. logger.log(`Recording format switched to ${newFormat}`);
  228. // will be used next time
  229. }
  230. /**
  231. * Returns the local recording stats.
  232. *
  233. * @returns {RecordingStats}
  234. */
  235. getLocalStats(): RecordingStats {
  236. return {
  237. currentSessionToken: this._currentSessionToken,
  238. isRecording: this._state === ControllerState.RECORDING,
  239. recordedBytes: 0,
  240. recordedLength: 0
  241. };
  242. }
  243. getParticipantsStats: () => *;
  244. /**
  245. * Returns the remote participants' local recording stats.
  246. *
  247. * @returns {*}
  248. */
  249. getParticipantsStats() {
  250. const members
  251. = this._conference.getParticipants()
  252. .map(member => {
  253. return {
  254. id: member.getId(),
  255. displayName: member.getDisplayName(),
  256. recordingStats:
  257. JSON.parse(member.getProperty(PROPERTY_STATS) || '{}'),
  258. isSelf: false
  259. };
  260. });
  261. // transform into a dictionary,
  262. // for consistent ordering
  263. const result = {};
  264. for (let i = 0; i < members.length; ++i) {
  265. result[members[i].id] = members[i];
  266. }
  267. const localId = this._conference.myUserId();
  268. result[localId] = {
  269. id: localId,
  270. displayName: i18next.t('localRecording.localUser'),
  271. recordingStats: this.getLocalStats(),
  272. isSelf: true
  273. };
  274. return result;
  275. }
  276. _updateStats: () => void;
  277. /**
  278. * Sends out updates about the local recording stats via XMPP.
  279. *
  280. * @private
  281. * @returns {void}
  282. */
  283. _updateStats() {
  284. if (this._conference) {
  285. this._conference.setLocalParticipantProperty(PROPERTY_STATS,
  286. JSON.stringify(this.getLocalStats()));
  287. }
  288. }
  289. _onStartCommand: (*) => void;
  290. /**
  291. * Callback function for XMPP event.
  292. *
  293. * @private
  294. * @param {*} value - The event args.
  295. * @returns {void}
  296. */
  297. _onStartCommand(value) {
  298. const { sessionToken, format } = value.attributes;
  299. if (this._state === ControllerState.IDLE) {
  300. this._format = format;
  301. this._currentSessionToken = sessionToken;
  302. this._adapters[sessionToken]
  303. = this._createRecordingAdapter();
  304. this._doStartRecording();
  305. } else if (this._currentSessionToken !== sessionToken) {
  306. // we need to restart the recording
  307. this._doStopRecording().then(() => {
  308. this._format = format;
  309. this._currentSessionToken = sessionToken;
  310. this._adapters[sessionToken]
  311. = this._createRecordingAdapter();
  312. this._doStartRecording();
  313. });
  314. }
  315. }
  316. _onStopCommand: (*) => void;
  317. /**
  318. * Callback function for XMPP event.
  319. *
  320. * @private
  321. * @param {*} value - The event args.
  322. * @returns {void}
  323. */
  324. _onStopCommand(value) {
  325. if (this._state === ControllerState.RECORDING
  326. && this._currentSessionToken === value.attributes.sessionToken) {
  327. this._doStopRecording();
  328. }
  329. }
  330. /**
  331. * Generates a token that can be used to distinguish each
  332. * recording session.
  333. *
  334. * @returns {number}
  335. */
  336. _getRandomToken() {
  337. return Math.floor(Math.random() * 10000) + 1;
  338. }
  339. _doStartRecording: () => void;
  340. /**
  341. * Starts the recording locally.
  342. *
  343. * @private
  344. * @returns {void}
  345. */
  346. _doStartRecording() {
  347. if (this._state === ControllerState.IDLE) {
  348. this._state = ControllerState.RECORDING;
  349. const delegate = this._adapters[this._currentSessionToken];
  350. delegate.ensureInitialized()
  351. .then(() => delegate.start())
  352. .then(() => {
  353. logger.log('Local recording engaged.');
  354. const message = i18next.t('localRecording.messages.engaged');
  355. if (this.onNotify) {
  356. this.onNotify(message);
  357. }
  358. if (this.onStateChanged) {
  359. this.onStateChanged(true);
  360. }
  361. this._updateStats();
  362. })
  363. .catch(err => {
  364. logger.error('Failed to start local recording.', err);
  365. });
  366. }
  367. }
  368. _doStopRecording: () => Promise<void>;
  369. /**
  370. * Stops the recording locally.
  371. *
  372. * @private
  373. * @returns {Promise<void>}
  374. */
  375. _doStopRecording() {
  376. if (this._state === ControllerState.RECORDING) {
  377. const token = this._currentSessionToken;
  378. return this._adapters[this._currentSessionToken]
  379. .stop()
  380. .then(() => {
  381. this._state = ControllerState.IDLE;
  382. logger.log('Local recording unengaged.');
  383. this.downloadRecordedData(token);
  384. const message
  385. = i18next.t('localRecording.messages.finished',
  386. {
  387. token
  388. });
  389. if (this.onNotify) {
  390. this.onNotify(message);
  391. }
  392. if (this.onStateChanged) {
  393. this.onStateChanged(false);
  394. }
  395. this._updateStats();
  396. })
  397. .catch(err => {
  398. logger.error('Failed to stop local recording.', err);
  399. });
  400. }
  401. /* eslint-disable */
  402. return (Promise.resolve(): Promise<void>);
  403. // FIXME: better ways to satisfy flow and ESLint at the same time?
  404. /* eslint-enable */
  405. }
  406. /**
  407. * Creates a recording adapter according to the current recording format.
  408. *
  409. * @private
  410. * @returns {RecordingAdapter}
  411. */
  412. _createRecordingAdapter() {
  413. logger.debug('[RecordingController] creating recording'
  414. + ` adapter for ${this._format} format.`);
  415. switch (this._format) {
  416. case 'ogg':
  417. return new OggAdapter();
  418. case 'flac':
  419. return new FlacAdapter();
  420. case 'wav':
  421. return new WavAdapter();
  422. default:
  423. throw new Error(`Unknown format: ${this._format}`);
  424. }
  425. }
  426. }
  427. /**
  428. * Global singleton of {@code RecordingController}.
  429. */
  430. export const recordingController = new RecordingController();