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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. /* global APP, $, config, interfaceConfig */
  2. /*
  3. * Copyright @ 2015 Atlassian Pty Ltd
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. const logger = require('jitsi-meet-logger').getLogger(__filename);
  18. import UIEvents from '../../../service/UI/UIEvents';
  19. import UIUtil from '../util/UIUtil';
  20. import VideoLayout from '../videolayout/VideoLayout';
  21. import {
  22. JitsiRecordingStatus
  23. } from '../../../react/features/base/lib-jitsi-meet';
  24. import {
  25. sendAnalyticsEvent
  26. } from '../../../react/features/analytics';
  27. import { setToolboxEnabled } from '../../../react/features/toolbox';
  28. import { setNotificationsEnabled } from '../../../react/features/notifications';
  29. import {
  30. hideRecordingLabel,
  31. updateRecordingState
  32. } from '../../../react/features/recording';
  33. /**
  34. * Translation keys to use for display in the UI when recording the conference
  35. * but not streaming live.
  36. *
  37. * @private
  38. * @type {Object}
  39. */
  40. export const RECORDING_TRANSLATION_KEYS = {
  41. failedToStartKey: 'recording.failedToStart',
  42. recordingBusy: 'recording.busy',
  43. recordingBusyTitle: 'recording.busyTitle',
  44. recordingButtonTooltip: 'recording.buttonTooltip',
  45. recordingErrorKey: 'recording.error',
  46. recordingOffKey: 'recording.off',
  47. recordingOnKey: 'recording.on',
  48. recordingPendingKey: 'recording.pending',
  49. recordingTitle: 'dialog.recording',
  50. recordingUnavailable: 'recording.unavailable',
  51. recordingUnavailableParams: '$t(recording.serviceName)',
  52. recordingUnavailableTitle: 'recording.unavailableTitle'
  53. };
  54. /**
  55. * Translation keys to use for display in the UI when the recording mode is
  56. * currently streaming live.
  57. *
  58. * @private
  59. * @type {Object}
  60. */
  61. export const STREAMING_TRANSLATION_KEYS = {
  62. failedToStartKey: 'liveStreaming.failedToStart',
  63. recordingBusy: 'liveStreaming.busy',
  64. recordingBusyTitle: 'liveStreaming.busyTitle',
  65. recordingButtonTooltip: 'liveStreaming.buttonTooltip',
  66. recordingErrorKey: 'liveStreaming.error',
  67. recordingOffKey: 'liveStreaming.off',
  68. recordingOnKey: 'liveStreaming.on',
  69. recordingPendingKey: 'liveStreaming.pending',
  70. recordingTitle: 'dialog.liveStreaming',
  71. recordingUnavailable: 'recording.unavailable',
  72. recordingUnavailableParams: '$t(liveStreaming.serviceName)',
  73. recordingUnavailableTitle: 'liveStreaming.unavailableTitle'
  74. };
  75. /**
  76. * The dialog for user input.
  77. */
  78. let dialog = null;
  79. /**
  80. * Indicates if the recording button should be enabled.
  81. *
  82. * @returns {boolean} {true} if the
  83. * @private
  84. */
  85. function _isRecordingButtonEnabled() {
  86. return (
  87. interfaceConfig.TOOLBAR_BUTTONS.indexOf('recording') !== -1
  88. && config.enableRecording
  89. && APP.conference.isRecordingSupported());
  90. }
  91. /**
  92. * Request live stream token from the user.
  93. * @returns {Promise}
  94. */
  95. function _requestLiveStreamId() {
  96. const cancelButton
  97. = APP.translation.generateTranslationHTML('dialog.Cancel');
  98. const backButton = APP.translation.generateTranslationHTML('dialog.Back');
  99. const startStreamingButton
  100. = APP.translation.generateTranslationHTML('dialog.startLiveStreaming');
  101. const streamIdRequired
  102. = APP.translation.generateTranslationHTML(
  103. 'liveStreaming.streamIdRequired');
  104. const streamIdHelp
  105. = APP.translation.generateTranslationHTML(
  106. 'liveStreaming.streamIdHelp');
  107. return new Promise((resolve, reject) => {
  108. dialog = APP.UI.messageHandler.openDialogWithStates({
  109. state0: {
  110. titleKey: 'dialog.liveStreaming',
  111. html:
  112. `<input class="input-control"
  113. name="streamId" type="text"
  114. data-i18n="[placeholder]dialog.streamKey"
  115. autofocus><div style="text-align: right">
  116. <a class="helper-link" target="_new"
  117. href="${interfaceConfig.LIVE_STREAMING_HELP_LINK}">${
  118. streamIdHelp
  119. }</a></div>`,
  120. persistent: false,
  121. buttons: [
  122. { title: cancelButton,
  123. value: false },
  124. { title: startStreamingButton,
  125. value: true }
  126. ],
  127. focus: ':input:first',
  128. defaultButton: 1,
  129. submit(e, v, m, f) { // eslint-disable-line max-params
  130. e.preventDefault();
  131. if (v) {
  132. if (f.streamId && f.streamId.length > 0) {
  133. resolve(UIUtil.escapeHtml(f.streamId));
  134. dialog.close();
  135. return;
  136. }
  137. dialog.goToState('state1');
  138. return false;
  139. }
  140. reject(APP.UI.messageHandler.CANCEL);
  141. dialog.close();
  142. return false;
  143. }
  144. },
  145. state1: {
  146. titleKey: 'dialog.liveStreaming',
  147. html: streamIdRequired,
  148. persistent: false,
  149. buttons: [
  150. { title: cancelButton,
  151. value: false },
  152. { title: backButton,
  153. value: true }
  154. ],
  155. focus: ':input:first',
  156. defaultButton: 1,
  157. submit(e, v) {
  158. e.preventDefault();
  159. if (v === 0) {
  160. reject(APP.UI.messageHandler.CANCEL);
  161. dialog.close();
  162. } else {
  163. dialog.goToState('state0');
  164. }
  165. }
  166. }
  167. }, {
  168. close() {
  169. dialog = null;
  170. }
  171. });
  172. });
  173. }
  174. /**
  175. * Request recording token from the user.
  176. * @returns {Promise}
  177. */
  178. function _requestRecordingToken() {
  179. const titleKey = 'dialog.recordingToken';
  180. const msgString
  181. = `<input name="recordingToken" type="text"
  182. data-i18n="[placeholder]dialog.token"
  183. class="input-control"
  184. autofocus>`
  185. ;
  186. return new Promise((resolve, reject) => {
  187. dialog = APP.UI.messageHandler.openTwoButtonDialog({
  188. titleKey,
  189. msgString,
  190. leftButtonKey: 'dialog.Save',
  191. submitFunction(e, v, m, f) { // eslint-disable-line max-params
  192. if (v && f.recordingToken) {
  193. resolve(UIUtil.escapeHtml(f.recordingToken));
  194. } else {
  195. reject(APP.UI.messageHandler.CANCEL);
  196. }
  197. },
  198. closeFunction() {
  199. dialog = null;
  200. },
  201. focus: ':input:first'
  202. });
  203. });
  204. }
  205. /**
  206. * Shows a prompt dialog to the user when they have toggled off the recording.
  207. *
  208. * @param recordingType the recording type
  209. * @returns {Promise}
  210. * @private
  211. */
  212. function _showStopRecordingPrompt(recordingType) {
  213. let title;
  214. let message;
  215. let buttonKey;
  216. if (recordingType === 'jibri') {
  217. title = 'dialog.liveStreaming';
  218. message = 'dialog.stopStreamingWarning';
  219. buttonKey = 'dialog.stopLiveStreaming';
  220. } else {
  221. title = 'dialog.recording';
  222. message = 'dialog.stopRecordingWarning';
  223. buttonKey = 'dialog.stopRecording';
  224. }
  225. return new Promise((resolve, reject) => {
  226. dialog = APP.UI.messageHandler.openTwoButtonDialog({
  227. titleKey: title,
  228. msgKey: message,
  229. leftButtonKey: buttonKey,
  230. submitFunction: (e, v) => (v ? resolve : reject)(),
  231. closeFunction: () => {
  232. dialog = null;
  233. }
  234. });
  235. });
  236. }
  237. /**
  238. * Checks whether if the given status is either PENDING or RETRYING
  239. * @param status {JitsiRecordingStatus} Jibri status to be checked
  240. * @returns {boolean} true if the condition is met or false otherwise.
  241. */
  242. function isStartingStatus(status) {
  243. return (
  244. status === JitsiRecordingStatus.PENDING
  245. || status === JitsiRecordingStatus.RETRYING
  246. );
  247. }
  248. /**
  249. * Manages the recording user interface and user experience.
  250. * @type {{init, initRecordingButton, showRecordingButton, updateRecordingState,
  251. * updateRecordingUI, checkAutoRecord}}
  252. */
  253. const Recording = {
  254. /**
  255. * Initializes the recording UI.
  256. */
  257. init(eventEmitter, recordingType) {
  258. this.eventEmitter = eventEmitter;
  259. this.recordingType = recordingType;
  260. this.updateRecordingState(APP.conference.getRecordingState());
  261. if (recordingType === 'jibri') {
  262. this.baseClass = 'fa fa-play-circle';
  263. Object.assign(this, STREAMING_TRANSLATION_KEYS);
  264. } else {
  265. this.baseClass = 'icon-recEnable';
  266. Object.assign(this, RECORDING_TRANSLATION_KEYS);
  267. }
  268. // XXX Due to the React-ification of Toolbox, the HTMLElement with id
  269. // toolbar_button_record may not exist yet.
  270. $(document).on(
  271. 'click',
  272. '#toolbar_button_record',
  273. ev => this._onToolbarButtonClick(ev));
  274. // If I am a recorder then I publish my recorder custom role to notify
  275. // everyone.
  276. if (config.iAmRecorder) {
  277. VideoLayout.enableDeviceAvailabilityIcons(
  278. APP.conference.getMyUserId(), false);
  279. // in case of iAmSipGateway keep local video visible
  280. if (!config.iAmSipGateway) {
  281. VideoLayout.setLocalVideoVisible(false);
  282. }
  283. APP.store.dispatch(setToolboxEnabled(false));
  284. APP.store.dispatch(setNotificationsEnabled(false));
  285. APP.UI.messageHandler.enablePopups(false);
  286. }
  287. },
  288. /**
  289. * Initialise the recording button.
  290. */
  291. initRecordingButton() {
  292. const selector = $('#toolbar_button_record');
  293. selector.addClass(this.baseClass);
  294. selector.attr('data-i18n', `[content]${this.recordingButtonTooltip}`);
  295. APP.translation.translateElement(selector);
  296. },
  297. /**
  298. * Shows or hides the 'recording' button.
  299. * @param show {true} to show the recording button, {false} to hide it
  300. */
  301. showRecordingButton(show) {
  302. const shouldShow = show && _isRecordingButtonEnabled();
  303. const id = 'toolbar_button_record';
  304. UIUtil.setVisible(id, shouldShow);
  305. },
  306. /**
  307. * Updates the recording state UI.
  308. * @param recordingState gives us the current recording state
  309. */
  310. updateRecordingState(recordingState) {
  311. // I'm the recorder, so I don't want to see any UI related to states.
  312. if (config.iAmRecorder) {
  313. return;
  314. }
  315. // If there's no state change, we ignore the update.
  316. if (!recordingState || this.currentState === recordingState) {
  317. return;
  318. }
  319. this.updateRecordingUI(recordingState);
  320. },
  321. /**
  322. * Sets the state of the recording button.
  323. * @param recordingState gives us the current recording state
  324. */
  325. updateRecordingUI(recordingState) {
  326. const oldState = this.currentState;
  327. this.currentState = recordingState;
  328. let labelDisplayConfiguration;
  329. switch (recordingState) {
  330. case JitsiRecordingStatus.ON:
  331. case JitsiRecordingStatus.RETRYING: {
  332. labelDisplayConfiguration = {
  333. centered: false,
  334. key: this.recordingOnKey,
  335. showSpinner: recordingState === JitsiRecordingStatus.RETRYING
  336. };
  337. this._setToolbarButtonToggled(true);
  338. break;
  339. }
  340. case JitsiRecordingStatus.OFF:
  341. case JitsiRecordingStatus.BUSY:
  342. case JitsiRecordingStatus.FAILED:
  343. case JitsiRecordingStatus.UNAVAILABLE: {
  344. const wasInStartingStatus = isStartingStatus(oldState);
  345. // We don't want UI changes if this is an availability change.
  346. if (oldState !== JitsiRecordingStatus.ON && !wasInStartingStatus) {
  347. APP.store.dispatch(updateRecordingState({ recordingState }));
  348. return;
  349. }
  350. labelDisplayConfiguration = {
  351. centered: true,
  352. key: wasInStartingStatus
  353. ? this.failedToStartKey
  354. : this.recordingOffKey
  355. };
  356. this._setToolbarButtonToggled(false);
  357. setTimeout(() => {
  358. APP.store.dispatch(hideRecordingLabel());
  359. }, 5000);
  360. break;
  361. }
  362. case JitsiRecordingStatus.PENDING: {
  363. labelDisplayConfiguration = {
  364. centered: true,
  365. key: this.recordingPendingKey
  366. };
  367. this._setToolbarButtonToggled(false);
  368. break;
  369. }
  370. case JitsiRecordingStatus.ERROR: {
  371. labelDisplayConfiguration = {
  372. centered: true,
  373. key: this.recordingErrorKey
  374. };
  375. this._setToolbarButtonToggled(false);
  376. break;
  377. }
  378. // Return an empty label display configuration to indicate no label
  379. // should be displayed. The JitsiRecordingStatus.AVAIABLE case is
  380. // handled here.
  381. default: {
  382. labelDisplayConfiguration = null;
  383. }
  384. }
  385. APP.store.dispatch(updateRecordingState({
  386. labelDisplayConfiguration,
  387. recordingState
  388. }));
  389. },
  390. // checks whether recording is enabled and whether we have params
  391. // to start automatically recording
  392. checkAutoRecord() {
  393. if (_isRecordingButtonEnabled && config.autoRecord) {
  394. this.predefinedToken = UIUtil.escapeHtml(config.autoRecordToken);
  395. this.eventEmitter.emit(UIEvents.RECORDING_TOGGLED,
  396. this.predefinedToken);
  397. }
  398. },
  399. /**
  400. * Handles {@code click} on {@code toolbar_button_record}.
  401. *
  402. * @returns {void}
  403. */
  404. _onToolbarButtonClick() {
  405. if (dialog) {
  406. return;
  407. }
  408. sendAnalyticsEvent('recording.clicked');
  409. switch (this.currentState) {
  410. case JitsiRecordingStatus.ON:
  411. case JitsiRecordingStatus.RETRYING:
  412. case JitsiRecordingStatus.PENDING: {
  413. _showStopRecordingPrompt(this.recordingType).then(
  414. () => {
  415. this.eventEmitter.emit(UIEvents.RECORDING_TOGGLED);
  416. sendAnalyticsEvent('recording.stopped');
  417. },
  418. () => {}); // eslint-disable-line no-empty-function
  419. break;
  420. }
  421. case JitsiRecordingStatus.AVAILABLE:
  422. case JitsiRecordingStatus.OFF: {
  423. if (this.recordingType === 'jibri') {
  424. _requestLiveStreamId()
  425. .then(streamId => {
  426. this.eventEmitter.emit(
  427. UIEvents.RECORDING_TOGGLED,
  428. { streamId });
  429. sendAnalyticsEvent('recording.started');
  430. })
  431. .catch(reason => {
  432. if (reason === APP.UI.messageHandler.CANCEL) {
  433. sendAnalyticsEvent('recording.canceled');
  434. } else {
  435. logger.error(reason);
  436. }
  437. });
  438. } else {
  439. if (this.predefinedToken) {
  440. this.eventEmitter.emit(
  441. UIEvents.RECORDING_TOGGLED,
  442. { token: this.predefinedToken });
  443. sendAnalyticsEvent('recording.started');
  444. return;
  445. }
  446. _requestRecordingToken().then(token => {
  447. this.eventEmitter.emit(
  448. UIEvents.RECORDING_TOGGLED,
  449. { token });
  450. sendAnalyticsEvent('recording.started');
  451. })
  452. .catch(reason => {
  453. if (reason === APP.UI.messageHandler.CANCEL) {
  454. sendAnalyticsEvent('recording.canceled');
  455. } else {
  456. logger.error(reason);
  457. }
  458. });
  459. }
  460. break;
  461. }
  462. case JitsiRecordingStatus.BUSY: {
  463. APP.UI.messageHandler.showWarning({
  464. descriptionKey: this.recordingBusy,
  465. titleKey: this.recordingBusyTitle
  466. });
  467. break;
  468. }
  469. default: {
  470. APP.UI.messageHandler.showError({
  471. descriptionKey: this.recordingUnavailable,
  472. descriptionArguments: {
  473. serviceName: this.recordingUnavailableParams },
  474. titleKey: this.recordingUnavailableTitle
  475. });
  476. }
  477. }
  478. },
  479. /**
  480. * Sets the toggled state of the recording toolbar button.
  481. *
  482. * @param {boolean} isToggled indicates if the button should be toggled
  483. * or not
  484. */
  485. _setToolbarButtonToggled(isToggled) {
  486. $('#toolbar_button_record').toggleClass('toggled', isToggled);
  487. }
  488. };
  489. export default Recording;