diff --git a/conference.js b/conference.js
index c6a9b8d0e..7c31dfe9a 100644
--- a/conference.js
+++ b/conference.js
@@ -109,6 +109,7 @@ import {
} from './react/features/overlay';
import { setSharedVideoStatus } from './react/features/shared-video';
import { isButtonEnabled } from './react/features/toolbox';
+import { endpointMessageReceived } from './react/features/subtitles';
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -1875,6 +1876,10 @@ export default {
}
);
+ room.on(
+ JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
+ (...args) => APP.store.dispatch(endpointMessageReceived(...args)));
+
room.on(
JitsiConferenceEvents.LOCK_STATE_CHANGED,
(...args) => APP.store.dispatch(lockStateChanged(room, ...args)));
diff --git a/config.js b/config.js
index a6dfba769..7298c7283 100644
--- a/config.js
+++ b/config.js
@@ -252,7 +252,6 @@ var config = {
// maintenance at 01:00 AM GMT,
// noticeMessage: '',
-
// Stats
//
diff --git a/css/_transcription-subtitles.scss b/css/_transcription-subtitles.scss
new file mode 100644
index 000000000..b55a03bd3
--- /dev/null
+++ b/css/_transcription-subtitles.scss
@@ -0,0 +1,13 @@
+.transcription-subtitles{
+ bottom: 10%;
+ font-size: 16px;
+ font-weight: 1000;
+ opacity: 0.80;
+ position: absolute;
+ text-shadow: 0px 0px 1px rgba(0,0,0,0.3),
+ 0px 1px 1px rgba(0,0,0,0.3),
+ 1px 0px 1px rgba(0,0,0,0.3),
+ 0px 0px 1px rgba(0,0,0,0.3);
+ width: 100%;
+ z-index: $zindex2;
+}
diff --git a/css/main.scss b/css/main.scss
index b887be0f8..cfd1988b0 100644
--- a/css/main.scss
+++ b/css/main.scss
@@ -77,5 +77,5 @@
@import 'unsupported-browser/main';
@import 'modals/invite/add-people';
@import 'deep-linking/main';
-
+@import 'transcription-subtitles';
/* Modules END */
diff --git a/interface_config.js b/interface_config.js
index 529f342dd..2ac95586e 100644
--- a/interface_config.js
+++ b/interface_config.js
@@ -80,6 +80,14 @@ var interfaceConfig = {
DISABLE_FOCUS_INDICATOR: false,
DISABLE_DOMINANT_SPEAKER_INDICATOR: false,
+ /**
+ * Whether the speech to text transcription subtitles panel is disabled.
+ * If {@code undefined}, defaults to {@code false}.
+ *
+ * @type {boolean}
+ */
+ DISABLE_TRANSCRIPTION_SUBTITLES: false,
+
/**
* Whether the ringing sound in the call/ring overlay is disabled. If
* {@code undefined}, defaults to {@code false}.
diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js
index 330168c27..5a537e05b 100644
--- a/react/features/base/conference/actions.js
+++ b/react/features/base/conference/actions.js
@@ -54,7 +54,6 @@ import {
getCurrentConference,
sendLocalParticipant
} from './functions';
-
import type { Dispatch } from 'redux';
const logger = require('jitsi-meet-logger').getLogger(__filename);
diff --git a/react/features/large-video/components/LargeVideo.web.js b/react/features/large-video/components/LargeVideo.web.js
index 27ea58b72..2cc1fe952 100644
--- a/react/features/large-video/components/LargeVideo.web.js
+++ b/react/features/large-video/components/LargeVideo.web.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Watermarks } from '../../base/react';
+import { TranscriptionSubtitles } from '../../subtitles/';
import Labels from './Labels';
@@ -70,6 +71,8 @@ export default class LargeVideo extends Component<*> {
muted = { true } />
+ { interfaceConfig.DISABLE_TRANSCRIPTION_SUBTITLES
+ ? null : }
{ this.props.hideVideoQualityLabel
? null : }
diff --git a/react/features/subtitles/actionTypes.js b/react/features/subtitles/actionTypes.js
new file mode 100644
index 000000000..db8509bb7
--- /dev/null
+++ b/react/features/subtitles/actionTypes.js
@@ -0,0 +1,46 @@
+/**
+ * The type of (redux) action which indicates that a transcript with
+ * a new message_id is received.
+ *
+ * {
+ * type: ADD_TRANSCRIPT_MESSAGE,
+ * transcriptMessageID: string,
+ * participantName: string
+ * }
+ */
+export const ADD_TRANSCRIPT_MESSAGE = Symbol('ADD_TRANSCRIPT_MESSAGE');
+
+/**
+ * The type of (redux) action which indicates that an endpoint message
+ * sent by another participant to the data channel is received.
+ *
+ * {
+ * type: ENDPOINT_MESSAGE_RECEIVED,
+ * participant: Object,
+ * json: Object
+ * }
+ */
+export const ENDPOINT_MESSAGE_RECEIVED = Symbol('ENDPOINT_MESSAGE_RECEIVED');
+
+/**
+ * The type of (redux) action which indicates that an existing transcript
+ * has to be removed from the state.
+ *
+ * {
+ * type: REMOVE_TRANSCRIPT_MESSAGE,
+ * transciptMessageID: string,
+ * }
+ */
+export const REMOVE_TRANSCRIPT_MESSAGE = Symbol('REMOVE_TRANSCRIPT_MESSAGE');
+
+/**
+ * The type of (redux) action which indicates that a transcript with an
+ * existing message_id to be updated is received.
+ *
+ * {
+ * type: UPDATE_TRANSCRIPT_MESSAGE,
+ * transcriptMessageID: string,
+ * newTranscriptMessage: Object
+ * }
+ */
+export const UPDATE_TRANSCRIPT_MESSAGE = Symbol('UPDATE_TRANSCRIPT_MESSAGE');
diff --git a/react/features/subtitles/actions.js b/react/features/subtitles/actions.js
new file mode 100644
index 000000000..461bdf50b
--- /dev/null
+++ b/react/features/subtitles/actions.js
@@ -0,0 +1,84 @@
+// @flow
+
+import {
+ ADD_TRANSCRIPT_MESSAGE,
+ ENDPOINT_MESSAGE_RECEIVED,
+ REMOVE_TRANSCRIPT_MESSAGE,
+ UPDATE_TRANSCRIPT_MESSAGE
+} from './actionTypes';
+
+/**
+ * Signals that a transcript with a new message_id is received.
+ *
+ * @param {string} transcriptMessageID - The new message_id.
+ * @param {string} participantName - The participant name of the sender.
+ * @returns {{
+ * type: ADD_TRANSCRIPT_MESSAGE,
+ * transcriptMessageID: string,
+ * participantName: string
+ * }}
+ */
+export function addTranscriptMessage(transcriptMessageID: string,
+ participantName: string) {
+ return {
+ type: ADD_TRANSCRIPT_MESSAGE,
+ transcriptMessageID,
+ participantName
+ };
+}
+
+/**
+ * Signals that a participant sent an endpoint message on the data channel.
+ *
+ * @param {Object} participant - The participant details sending the message.
+ * @param {Object} json - The json carried by the endpoint message.
+ * @returns {{
+ * type: ENDPOINT_MESSAGE_RECEIVED,
+ * participant: Object,
+ * json: Object
+ * }}
+ */
+export function endpointMessageReceived(participant: Object, json: Object) {
+ return {
+ type: ENDPOINT_MESSAGE_RECEIVED,
+ participant,
+ json
+ };
+}
+
+/**
+ * Signals that a transcript has to be removed from the state.
+ *
+ * @param {string} transcriptMessageID - The message_id to be removed.
+ * @returns {{
+ * type: REMOVE_TRANSCRIPT_MESSAGE,
+ * transcriptMessageID: string,
+ * }}
+ */
+export function removeTranscriptMessage(transcriptMessageID: string) {
+ return {
+ type: REMOVE_TRANSCRIPT_MESSAGE,
+ transcriptMessageID
+ };
+}
+
+/**
+ * Signals that a transcript with an existing message_id to be updated
+ * is received.
+ *
+ * @param {string} transcriptMessageID -The transcript message_id to be updated.
+ * @param {Object} newTranscriptMessage - The updated transcript message.
+ * @returns {{
+ * type: UPDATE_TRANSCRIPT_MESSAGE,
+ * transcriptMessageID: string,
+ * newTranscriptMessage: Object
+ * }}
+ */
+export function updateTranscriptMessage(transcriptMessageID: string,
+ newTranscriptMessage: Object) {
+ return {
+ type: UPDATE_TRANSCRIPT_MESSAGE,
+ transcriptMessageID,
+ newTranscriptMessage
+ };
+}
diff --git a/react/features/subtitles/components/TranscriptionSubtitles.native.js b/react/features/subtitles/components/TranscriptionSubtitles.native.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/react/features/subtitles/components/TranscriptionSubtitles.web.js b/react/features/subtitles/components/TranscriptionSubtitles.web.js
new file mode 100644
index 000000000..0f883959f
--- /dev/null
+++ b/react/features/subtitles/components/TranscriptionSubtitles.web.js
@@ -0,0 +1,78 @@
+// @flow
+
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+
+/**
+ * The type of the React {@code Component} props of
+ * {@link TranscriptionSubtitles}.
+ */
+type Props = {
+
+ /**
+ * Map of transcriptMessageID's with corresponding transcriptMessage.
+ */
+ _transcriptMessages: Map
+};
+
+/**
+ * React {@code Component} which can display speech-to-text results from
+ * Jigasi as subtitles.
+ */
+class TranscriptionSubtitles extends Component {
+
+ /**
+ * Implements React's {@link Component#render()}.
+ *
+ * @inheritdoc
+ * @returns {ReactElement}
+ */
+ render() {
+ const paragraphs = [];
+
+ for (const [ transcriptMessageID, transcriptMessage ]
+ of this.props._transcriptMessages) {
+ let text;
+
+ if (transcriptMessage) {
+ text = `${transcriptMessage.participantName}: `;
+
+ if (transcriptMessage.final) {
+ text += transcriptMessage.final;
+ } else {
+ const stable = transcriptMessage.stable || '';
+ const unstable = transcriptMessage.unstable || '';
+
+ text += stable + unstable;
+ }
+ paragraphs.push(
+ { text }
+ );
+ }
+ }
+
+ return (
+
+ { paragraphs }
+
+ );
+ }
+}
+
+
+/**
+ * Maps the transcriptionSubtitles in the Redux state to the associated
+ * props of {@code TranscriptionSubtitles}.
+ *
+ * @param {Object} state - The Redux state.
+ * @private
+ * @returns {{
+ * _transcriptMessages: Map
+ * }}
+ */
+function _mapStateToProps(state) {
+ return {
+ _transcriptMessages: state['features/subtitles'].transcriptMessages
+ };
+}
+export default connect(_mapStateToProps)(TranscriptionSubtitles);
diff --git a/react/features/subtitles/components/index.js b/react/features/subtitles/components/index.js
new file mode 100644
index 000000000..531c68d72
--- /dev/null
+++ b/react/features/subtitles/components/index.js
@@ -0,0 +1 @@
+export { default as TranscriptionSubtitles } from './TranscriptionSubtitles';
diff --git a/react/features/subtitles/index.js b/react/features/subtitles/index.js
new file mode 100644
index 000000000..a29aa08e0
--- /dev/null
+++ b/react/features/subtitles/index.js
@@ -0,0 +1,6 @@
+export * from './actions';
+export * from './actionTypes';
+export * from './components';
+
+import './middleware';
+import './reducer';
diff --git a/react/features/subtitles/middleware.js b/react/features/subtitles/middleware.js
new file mode 100644
index 000000000..6f687b186
--- /dev/null
+++ b/react/features/subtitles/middleware.js
@@ -0,0 +1,112 @@
+import { MiddlewareRegistry } from '../base/redux';
+
+import { ENDPOINT_MESSAGE_RECEIVED } from './actionTypes';
+import {
+ addTranscriptMessage,
+ removeTranscriptMessage,
+ updateTranscriptMessage
+} from './actions';
+
+const logger = require('jitsi-meet-logger').getLogger(__filename);
+
+/**
+* Time after which the rendered subtitles will be removed.
+*/
+const REMOVE_AFTER_MS = 3000;
+
+/**
+ * Middleware that catches actions related to transcript messages
+ * to be rendered in {@link TranscriptionSubtitles }
+ *
+ * @param {Store} store - Redux store.
+ * @returns {Function}
+ */
+MiddlewareRegistry.register(store => next => action => {
+
+ switch (action.type) {
+ case ENDPOINT_MESSAGE_RECEIVED:
+ return _endpointMessageReceived(store, next, action);
+ }
+
+ return next(action);
+});
+
+/**
+ * Notifies the feature transcription that the action
+ * {@code ENDPOINT_MESSAGE_RECEIVED} is being dispatched within a specific redux
+ * store.
+ *
+ * @param {Store} store - The redux store in which the specified {@code action}
+ * is being dispatched.
+ * @param {Dispatch} next - The redux {@code dispatch} function to
+ * dispatch the specified {@code action} to the specified {@code store}.
+ * @param {Action} action - The redux action {@code ENDPOINT_MESSAGE_RECEIVED}
+ * which is being dispatched in the specified {@code store}.
+ * @private
+ * @returns {Object} The value returned by {@code next(action)}.
+ */
+function _endpointMessageReceived({ dispatch, getState }, next, action) {
+ const json = action.json;
+
+ try {
+
+ // Let's first check if the given object has the correct
+ // type in the json, which identifies it as a json message sent
+ // from Jigasi with speech-to-to-text results
+ if (json.type === 'transcription-result') {
+ // Extract the useful data from the json.
+ const isInterim = json.is_interim;
+ const participantName = json.participant.name;
+ const stability = json.stability;
+ const text = json.transcript[0].text;
+ const transcriptMessageID = json.message_id;
+
+ // If this is the first result with the unique message ID,
+ // we add it to the state along with the name of the participant
+ // who said given text
+ if (!getState()['features/subtitles']
+ .transcriptMessages.has(transcriptMessageID)) {
+ dispatch(addTranscriptMessage(transcriptMessageID,
+ participantName));
+ }
+ const { transcriptMessages } = getState()['features/subtitles'];
+ const newTranscriptMessage
+ = { ...transcriptMessages.get(transcriptMessageID) };
+
+ // If this is final result, update the state as a final result
+ // and start a count down to remove the subtitle from the state
+ if (!isInterim) {
+
+ newTranscriptMessage.final = text;
+ dispatch(updateTranscriptMessage(transcriptMessageID,
+ newTranscriptMessage));
+
+ setTimeout(() => {
+ dispatch(removeTranscriptMessage(transcriptMessageID));
+ }, REMOVE_AFTER_MS);
+ } else if (stability > 0.85) {
+
+ // If the message has a high stability, we can update the
+ // stable field of the state and remove the previously
+ // unstable results
+
+ newTranscriptMessage.stable = text;
+ newTranscriptMessage.unstable = undefined;
+ dispatch(updateTranscriptMessage(transcriptMessageID,
+ newTranscriptMessage));
+ } else {
+ // Otherwise, this result has an unstable result, which we
+ // add to the state. The unstable result will be appended
+ // after the stable part.
+
+ newTranscriptMessage.unstable = text;
+ dispatch(updateTranscriptMessage(transcriptMessageID,
+ newTranscriptMessage));
+ }
+ }
+ } catch (error) {
+ logger.error('Error occurred while updating transcriptions\n', error);
+ }
+
+ return next(action);
+}
diff --git a/react/features/subtitles/reducer.js b/react/features/subtitles/reducer.js
new file mode 100644
index 000000000..69a9f3ba7
--- /dev/null
+++ b/react/features/subtitles/reducer.js
@@ -0,0 +1,99 @@
+import { ReducerRegistry } from '../base/redux';
+
+import {
+ ADD_TRANSCRIPT_MESSAGE,
+ REMOVE_TRANSCRIPT_MESSAGE,
+ UPDATE_TRANSCRIPT_MESSAGE
+} from './actionTypes';
+
+/**
+ * Default State for 'features/transcription' feature
+ */
+const defaultState = {
+ transcriptMessages: new Map()
+};
+
+/**
+ * Listen for actions for the transcription feature to be used by the actions
+ * to update the rendered transcription subtitles.
+ */
+ReducerRegistry.register('features/subtitles', (
+ state = defaultState, action) => {
+ switch (action.type) {
+ case ADD_TRANSCRIPT_MESSAGE:
+ return _addTranscriptMessage(state, action);
+
+ case REMOVE_TRANSCRIPT_MESSAGE:
+ return _removeTranscriptMessage(state, action);
+
+ case UPDATE_TRANSCRIPT_MESSAGE:
+ return _updateTranscriptMessage(state, action);
+ }
+
+ return state;
+});
+
+/**
+ * Reduces a specific Redux action ADD_TRANSCRIPT_MESSAGE of the feature
+ * transcription.
+ *
+ * @param {Object} state - The Redux state of the feature transcription.
+ * @param {Action} action -The Redux action ADD_TRANSCRIPT_MESSAGE to reduce.
+ * @returns {Object} The new state of the feature transcription after the
+ * reduction of the specified action.
+ */
+function _addTranscriptMessage(state,
+ { transcriptMessageID, participantName }) {
+ const newTranscriptMessages = new Map(state.transcriptMessages);
+
+ // Adds a new key,value pair to the Map once a new message arrives.
+ newTranscriptMessages.set(transcriptMessageID, { participantName });
+
+ return {
+ ...state,
+ transcriptMessages: newTranscriptMessages
+ };
+}
+
+/**
+ * Reduces a specific Redux action REMOVE_TRANSCRIPT_MESSAGE of the feature
+ * transcription.
+ *
+ * @param {Object} state - The Redux state of the feature transcription.
+ * @param {Action} action -The Redux action REMOVE_TRANSCRIPT_MESSAGE to reduce.
+ * @returns {Object} The new state of the feature transcription after the
+ * reduction of the specified action.
+ */
+function _removeTranscriptMessage(state, { transcriptMessageID }) {
+ const newTranscriptMessages = new Map(state.transcriptMessages);
+
+ // Deletes the key from Map once a final message arrives.
+ newTranscriptMessages.delete(transcriptMessageID);
+
+ return {
+ ...state,
+ transcriptMessages: newTranscriptMessages
+ };
+}
+
+/**
+ * Reduces a specific Redux action UPDATE_TRANSCRIPT_MESSAGE of the feature
+ * transcription.
+ *
+ * @param {Object} state - The Redux state of the feature transcription.
+ * @param {Action} action -The Redux action UPDATE_TRANSCRIPT_MESSAGE to reduce.
+ * @returns {Object} The new state of the feature transcription after the
+ * reduction of the specified action.
+ */
+function _updateTranscriptMessage(state,
+ { transcriptMessageID, newTranscriptMessage }) {
+ const newTranscriptMessages = new Map(state.transcriptMessages);
+
+ // Updates the new message for the given key in the Map.
+ newTranscriptMessages.set(transcriptMessageID, newTranscriptMessage);
+
+ return {
+ ...state,
+ transcriptMessages: newTranscriptMessages
+ };
+}