diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index d6deb1ce5..ef30ea004 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -17,6 +17,7 @@ import { participantRoleChanged, participantUpdated } from '../participants'; +import { endpointMessageReceived } from '../../subtitles'; import { getLocalTracks, trackAdded, trackRemoved } from '../tracks'; import { getJitsiMeetGlobalNS } from '../util'; @@ -137,6 +138,10 @@ function _addConferenceListeners(conference, dispatch) { JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED, id => dispatch(dominantSpeakerChanged(id, conference))); + conference.on( + JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, + (...args) => dispatch(endpointMessageReceived(...args))); + conference.on( JitsiConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED, (...args) => dispatch(participantConnectionStatusChanged(...args))); diff --git a/react/features/conference/components/Conference.native.js b/react/features/conference/components/Conference.native.js index efc69bbb3..312877436 100644 --- a/react/features/conference/components/Conference.native.js +++ b/react/features/conference/components/Conference.native.js @@ -22,6 +22,7 @@ import { FILMSTRIP_SIZE, Filmstrip, isFilmstripVisible } from '../../filmstrip'; import { LargeVideo } from '../../large-video'; import { CalleeInfoContainer } from '../../invite'; import { NotificationsContainer } from '../../notifications'; +import { Captions } from '../../subtitles'; import { setToolboxVisible, Toolbox } from '../../toolbox'; import styles from './styles'; @@ -283,18 +284,20 @@ class Conference extends Component { - { - - /** - * Notifications are rendered on the very top of other - * components like subtitles, toolbox and filmstrip. - */ + {/* + * Notifications are rendered on the very top of other + * components like subtitles, toolbox and filmstrip. + */ this._renderNotificationsContainer() } + + + {/* * The Toolbox is in a stacking layer bellow the Filmstrip. */} + {/* * The Filmstrip is in a stacking layer above the * LargeVideo. The LargeVideo and the Filmstrip form what @@ -369,28 +372,26 @@ class Conference extends Component { } /** - * Renders a container for notifications to be displayed by - * the base/notifications feature. + * Renders a container for notifications to be displayed by the + * base/notifications feature. * - * @returns {React$Element} * @private + * @returns {React$Element} */ _renderNotificationsContainer() { - const notificationsStyle = { }; + const notificationsStyle = {}; - /** - * In the landscape mode (wide) there's problem with notifications being - * shadowed by the filmstrip rendered on the right. This makes the "x" - * button not clickable. In order to avoid that a margin of - * the filmstrip's size is added to the right. - * - * Pawel: after many attempts I failed to make notifications adjust to - * their contents width because of column and rows being used in - * the flex layout. The only option that seemed to limit - * the notification's size was explicit 'width' value which is not - * better than the margin added here. - */ - if (!isNarrowAspectRatio(this) && this.props._filmstripVisible) { + // In the landscape mode (wide) there's problem with notifications being + // shadowed by the filmstrip rendered on the right. This makes the "x" + // button not clickable. In order to avoid that a margin of the + // filmstrip's size is added to the right. + // + // Pawel: after many attempts I failed to make notifications adjust to + // their contents width because of column and rows being used in the + // flex layout. The only option that seemed to limit the notification's + // size was explicit 'width' value which is not better than the margin + // added here. + if (this.props._filmstripVisible && !isNarrowAspectRatio(this)) { notificationsStyle.marginRight = FILMSTRIP_SIZE; } diff --git a/react/features/large-video/components/LargeVideo.web.js b/react/features/large-video/components/LargeVideo.web.js index 08e08d8de..d8acd6554 100644 --- a/react/features/large-video/components/LargeVideo.web.js +++ b/react/features/large-video/components/LargeVideo.web.js @@ -1,34 +1,37 @@ -/* @flow */ +// @flow -import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { Watermarks } from '../../base/react'; -import { TranscriptionSubtitles } from '../../subtitles/'; +import { Captions } from '../../subtitles/'; import Labels from './Labels'; declare var interfaceConfig: Object; +/** + * The type of the React {@code Component} props of {@link LargeVideo}. + */ +type Props = { + + /** + * True if the {@code VideoQualityLabel} should not be displayed. + */ + hideVideoQualityLabel: boolean +}; + /** * Implements a React {@link Component} which represents the large video (a.k.a. * the conference participant who is on the local stage) on Web/React. * * @extends Component */ -export default class LargeVideo extends Component<*> { - static propTypes = { - /** - * True if the {@code VideoQualityLabel} should not be displayed. - */ - hideVideoQualityLabel: PropTypes.bool - }; - +export default class LargeVideo extends Component { /** * Implements React's {@link Component#render()}. * * @inheritdoc - * @returns {ReactElement} + * @returns {React$Element} */ render() { return ( @@ -52,18 +55,15 @@ export default class LargeVideo extends Component<*> {
- { - /** - * FIXME: the architecture of elements related to the - * large video and the naming. The background is not - * part of largeVideoWrapper because we are controlling - * the size of the video through largeVideoWrapper. - * That's why we need another container for the the - * background and the largeVideoWrapper in order to - * hide/show them. - */ - } + {/* + * FIXME: the architecture of elements related to the large + * video and the naming. The background is not part of + * largeVideoWrapper because we are controlling the size of + * the video through largeVideoWrapper. That's why we need + * another container for the background and the + * largeVideoWrapper in order to hide/show them. + */}
{ interfaceConfig.DISABLE_TRANSCRIPTION_SUBTITLES - ? null : } + || } { this.props.hideVideoQualityLabel - ? null : } + || }
); } diff --git a/react/features/subtitles/components/AbstractCaptions.js b/react/features/subtitles/components/AbstractCaptions.js new file mode 100644 index 000000000..3225b024e --- /dev/null +++ b/react/features/subtitles/components/AbstractCaptions.js @@ -0,0 +1,128 @@ +// @flow + +import { Component } from 'react'; + +/** + * {@code AbstractCaptions} properties. + */ +export type AbstractCaptionsProps = { + + /** + * Whether local participant is requesting to see subtitles. + */ + _requestingSubtitles: boolean, + + /** + * Transcript texts formatted with participant's name and final content. + * Mapped by id just to have the keys for convenience during the rendering + * process. + */ + _transcripts: Map +}; + +/** + * Abstract React {@code Component} which can display speech-to-text results + * from Jigasi as subtitles. + */ +export class AbstractCaptions + extends Component

{ + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {React$Element} + */ + render() { + const { _requestingSubtitles, _transcripts } = this.props; + + if (!_requestingSubtitles || !_transcripts.size) { + return null; + } + + const paragraphs = []; + + for (const [ id, text ] of _transcripts) { + paragraphs.push(this._renderParagraph(id, text)); + } + + return this._renderSubtitlesContainer(paragraphs); + } + + /** + * Renders the transcription text. + * + * @abstract + * @param {string} id - The ID of the transcript message from which the + * {@code text} has been created. + * @param {string} text - Subtitles text formatted with the participant's + * name. + * @protected + * @returns {React$Element} - The React element which displays the text. + */ + _renderParagraph: (id: string, text: string) => React$Element<*>; + + /** + * Renders the subtitles container. + * + * @abstract + * @param {Array} paragraphs - An array of elements created + * for each subtitle using the {@link _renderParagraph} method. + * @protected + * @returns {React$Element} - The subtitles container. + */ + _renderSubtitlesContainer: (Array>) => React$Element<*>; +} + +/** + * Formats the transcript messages into text by prefixing participant's name to + * avoid duplicating the effort on platform specific component. + * + * @param {Object} state - The redux state. + * @private + * @returns {Map} - Formatted transcript subtitles mapped by + * transcript message IDs. + */ +function _constructTranscripts(state: Object): Map { + const { _transcriptMessages } = state['features/subtitles']; + const transcripts = new Map(); + + for (const [ id, transcriptMessage ] of _transcriptMessages) { + if (transcriptMessage) { + let text = `${transcriptMessage.participantName}: `; + + if (transcriptMessage.final) { + text += transcriptMessage.final; + } else { + const stable = transcriptMessage.stable || ''; + const unstable = transcriptMessage.unstable || ''; + + text += stable + unstable; + } + + transcripts.set(id, text); + } + } + + return transcripts; +} + +/** + * Maps the transcriptionSubtitles in the redux state to the associated props of + * {@code AbstractCaptions}. + * + * @param {Object} state - The redux state. + * @private + * @returns {{ + * _requestingSubtitles: boolean, + * _transcripts: Map + * }} + */ +export function _abstractMapStateToProps(state: Object) { + const { _requestingSubtitles } = state['features/subtitles']; + + return { + _requestingSubtitles, + _transcripts: _constructTranscripts(state) + }; +} diff --git a/react/features/subtitles/components/Captions.native.js b/react/features/subtitles/components/Captions.native.js new file mode 100644 index 000000000..230e980ab --- /dev/null +++ b/react/features/subtitles/components/Captions.native.js @@ -0,0 +1,68 @@ +// @flow + +import React from 'react'; +import { connect } from 'react-redux'; + +import { Container, Text } from '../../base/react'; + +import { + _abstractMapStateToProps, + AbstractCaptions, + type AbstractCaptionsProps +} from './AbstractCaptions'; +import styles from './styles'; + +/** + * The type of the React {@code Component} props of {@link Captions}. + */ +type Props = AbstractCaptionsProps & { + onPress: Function +}; + +/** + * React {@code Component} which can display speech-to-text results from + * Jigasi as subtitles. + */ +class Captions + extends AbstractCaptions { + + /** + * Renders the transcription text. + * + * @param {string} id - The ID of the transcript message from which the + * {@code text} has been created. + * @param {string} text - Subtitles text formatted with the participant's + * name. + * @protected + * @returns {React$Element} - The React element which displays the text. + */ + _renderParagraph(id: string, text: string): React$Element<*> { + return ( + + { text } + + ); + } + + /** + * Renders the subtitles container. + * + * @param {Array} paragraphs - An array of elements created + * for each subtitle using the {@link _renderParagraph} method. + * @protected + * @returns {React$Element} - The subtitles container. + */ + _renderSubtitlesContainer( + paragraphs: Array>): React$Element<*> { + return ( + + { paragraphs } + + ); + } +} + +export default connect(_abstractMapStateToProps)(Captions); diff --git a/react/features/subtitles/components/Captions.web.js b/react/features/subtitles/components/Captions.web.js new file mode 100644 index 000000000..dc37528e7 --- /dev/null +++ b/react/features/subtitles/components/Captions.web.js @@ -0,0 +1,55 @@ +// @flow + +import React from 'react'; +import { connect } from 'react-redux'; + +import { + _abstractMapStateToProps, + AbstractCaptions, + type AbstractCaptionsProps as Props +} from './AbstractCaptions'; + +/** + * React {@code Component} which can display speech-to-text results from + * Jigasi as subtitles. + */ +class Captions + extends AbstractCaptions { + + /** + * Renders the transcription text. + * + * @param {string} id - The ID of the transcript message from which the + * {@code text} has been created. + * @param {string} text - Subtitles text formatted with the participant's + * name. + * @protected + * @returns {React$Element} - The React element which displays the text. + */ + _renderParagraph(id: string, text: string): React$Element<*> { + return ( +

+ { text } +

+ ); + } + + /** + * Renders the subtitles container. + * + * @param {Array} paragraphs - An array of elements created + * for each subtitle using the {@link _renderParagraph} method. + * @protected + * @returns {React$Element} - The subtitles container. + */ + _renderSubtitlesContainer( + paragraphs: Array>): React$Element<*> { + return ( +
+ { paragraphs } +
+ ); + } +} + +export default connect(_abstractMapStateToProps)(Captions); diff --git a/react/features/subtitles/components/TranscriptionSubtitles.native.js b/react/features/subtitles/components/TranscriptionSubtitles.native.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/react/features/subtitles/components/TranscriptionSubtitles.web.js b/react/features/subtitles/components/TranscriptionSubtitles.web.js deleted file mode 100644 index 96e3e103e..000000000 --- a/react/features/subtitles/components/TranscriptionSubtitles.web.js +++ /dev/null @@ -1,96 +0,0 @@ -// @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, - - /** - * Whether local participant is requesting to see subtitles - */ - _requestingSubtitles: Boolean -}; - -/** - * 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() { - if (!this.props._requestingSubtitles - || !this.props._transcriptMessages) { - return null; - } - - 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) { - const { - _transcriptMessages, - _requestingSubtitles - } = state['features/subtitles']; - - return { - _transcriptMessages, - _requestingSubtitles - }; -} -export default connect(_mapStateToProps)(TranscriptionSubtitles); diff --git a/react/features/subtitles/components/index.js b/react/features/subtitles/components/index.js index 175052d43..3b265460a 100644 --- a/react/features/subtitles/components/index.js +++ b/react/features/subtitles/components/index.js @@ -1,2 +1,2 @@ -export { default as TranscriptionSubtitles } from './TranscriptionSubtitles'; +export { default as Captions } from './Captions'; export { default as ClosedCaptionButton } from './ClosedCaptionButton'; diff --git a/react/features/subtitles/components/styles.js b/react/features/subtitles/components/styles.js new file mode 100644 index 000000000..7ab97a7ae --- /dev/null +++ b/react/features/subtitles/components/styles.js @@ -0,0 +1,31 @@ +// @flow + +import { BoxModel, ColorPalette, createStyleSheet } from '../../base/styles'; + +/** + * The styles of the React {@code Component}s of the feature subtitles. + */ +export default createStyleSheet({ + + /** + * Style for subtitle paragraph. + */ + subtitle: { + backgroundColor: ColorPalette.black, + borderRadius: BoxModel.margin / 4, + color: ColorPalette.white, + marginBottom: BoxModel.margin, + padding: BoxModel.padding / 2 + }, + + /** + * Style for the subtitles container. + */ + subtitlesContainer: { + alignItems: 'center', + flexDirection: 'column', + flexGrow: 0, + justifyContent: 'flex-end', + margin: BoxModel.margin + } +}); diff --git a/react/features/subtitles/middleware.js b/react/features/subtitles/middleware.js index 2a7ea040c..1017a46ff 100644 --- a/react/features/subtitles/middleware.js +++ b/react/features/subtitles/middleware.js @@ -2,14 +2,14 @@ import { MiddlewareRegistry } from '../base/redux'; -import { - ENDPOINT_MESSAGE_RECEIVED, - TOGGLE_REQUESTING_SUBTITLES -} from './actionTypes'; import { removeTranscriptMessage, updateTranscriptMessage } from './actions'; +import { + ENDPOINT_MESSAGE_RECEIVED, + TOGGLE_REQUESTING_SUBTITLES +} from './actionTypes'; const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -25,34 +25,35 @@ const JSON_TYPE_TRANSCRIPTION_RESULT = 'transcription-result'; */ const JSON_TYPE_TRANSLATION_RESULT = 'translation-result'; -/** - * The local participant property which is used to store the language - * preference for translation for a participant. - */ -const P_NAME_TRANSLATION_LANGUAGE = 'translation_language'; - /** * The local participant property which is used to set whether the local * participant wants to have a transcriber in the room. */ const P_NAME_REQUESTING_TRANSCRIPTION = 'requestingTranscription'; +/** + * The local participant property which is used to store the language + * preference for translation for a participant. + */ +const P_NAME_TRANSLATION_LANGUAGE = 'translation_language'; + /** * 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 } + * Middleware that catches actions related to transcript messages to be rendered + * in {@link Captions}. * - * @param {Store} store - Redux store. + * @param {Store} store - The redux store. * @returns {Function} */ MiddlewareRegistry.register(store => next => action => { switch (action.type) { case ENDPOINT_MESSAGE_RECEIVED: return _endpointMessageReceived(store, next, action); + case TOGGLE_REQUESTING_SUBTITLES: _requestingSubtitlesToggled(store); break; @@ -61,22 +62,6 @@ MiddlewareRegistry.register(store => next => action => { return next(action); }); -/** - * Toggle the local property 'requestingTranscription'. This will cause Jicofo - * and Jigasi to decide whether the transcriber needs to be in the room. - * - * @param {Store} store - The redux store. - * @private - * @returns {void} - */ -function _requestingSubtitlesToggled({ getState }) { - const { _requestingSubtitles } = getState()['features/subtitles']; - const { conference } = getState()['features/base/conference']; - - conference.setLocalParticipantProperty(P_NAME_REQUESTING_TRANSCRIPTION, - !_requestingSubtitles); -} - /** * Notifies the feature transcription that the action * {@code ENDPOINT_MESSAGE_RECEIVED} is being dispatched within a specific redux @@ -92,84 +77,81 @@ function _requestingSubtitlesToggled({ getState }) { * @returns {Object} The value returned by {@code next(action)}. */ function _endpointMessageReceived({ dispatch, getState }, next, action) { - if (!(action.json - && (action.json.type === JSON_TYPE_TRANSCRIPTION_RESULT - || action.json.type === JSON_TYPE_TRANSLATION_RESULT))) { + const { json } = action; + + if (!(json + && (json.type === JSON_TYPE_TRANSCRIPTION_RESULT + || json.type === JSON_TYPE_TRANSLATION_RESULT))) { return next(action); } - const json = action.json; + const state = getState(); const translationLanguage - = getState()['features/base/conference'].conference + = state['features/base/conference'].conference .getLocalParticipantProperty(P_NAME_TRANSLATION_LANGUAGE); try { const transcriptMessageID = json.message_id; const participantName = json.participant.name; - const isInterim = json.is_interim; - const stability = json.stability; if (json.type === JSON_TYPE_TRANSLATION_RESULT - && json.language === translationLanguage) { + && json.language === translationLanguage) { // Displays final results in the target language if translation is // enabled. const newTranscriptMessage = { - participantName, + clearTimeOut: undefined, final: json.text, - clearTimeOut: undefined + participantName }; - setClearerOnTranscriptMessage(dispatch, + _setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage); dispatch(updateTranscriptMessage(transcriptMessageID, newTranscriptMessage)); } else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT - && !translationLanguage) { + && !translationLanguage) { // Displays interim and final results without any translation if // translations are disabled. - const text = json.transcript[0].text; + const { text } = json.transcript[0]; // We update the previous transcript message with the same // message ID or adds a new transcript message if it does not // exist in the map. - const newTranscriptMessage - = { ...getState()['features/subtitles']._transcriptMessages - .get(transcriptMessageID) || { participantName } }; + const newTranscriptMessage = { + ...state['features/subtitles']._transcriptMessages + .get(transcriptMessageID) + || { participantName } + }; - setClearerOnTranscriptMessage(dispatch, + _setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage); // 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) { - + if (!json.is_interim) { newTranscriptMessage.final = text; - dispatch(updateTranscriptMessage(transcriptMessageID, - newTranscriptMessage)); - } else if (stability > 0.85) { - + } else if (json.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)); } + + dispatch( + updateTranscriptMessage( + transcriptMessageID, + newTranscriptMessage)); } } catch (error) { logger.error('Error occurred while updating transcriptions\n', error); @@ -178,6 +160,24 @@ function _endpointMessageReceived({ dispatch, getState }, next, action) { return next(action); } +/** + * Toggle the local property 'requestingTranscription'. This will cause Jicofo + * and Jigasi to decide whether the transcriber needs to be in the room. + * + * @param {Store} store - The redux store. + * @private + * @returns {void} + */ +function _requestingSubtitlesToggled({ getState }) { + const state = getState(); + const { _requestingSubtitles } = state['features/subtitles']; + const { conference } = state['features/base/conference']; + + conference.setLocalParticipantProperty( + P_NAME_REQUESTING_TRANSCRIPTION, + !_requestingSubtitles); +} + /** * Set a timeout on a TranscriptMessage object so it clears itself when it's not * updated. @@ -185,10 +185,9 @@ function _endpointMessageReceived({ dispatch, getState }, next, action) { * @param {Function} dispatch - Dispatch remove action to store. * @param {string} transcriptMessageID - The id of the message to remove. * @param {Object} transcriptMessage - The message to remove. - * * @returns {void} */ -function setClearerOnTranscriptMessage( +function _setClearerOnTranscriptMessage( dispatch, transcriptMessageID, transcriptMessage) { @@ -196,7 +195,8 @@ function setClearerOnTranscriptMessage( clearTimeout(transcriptMessage.clearTimeOut); } - transcriptMessage.clearTimeOut = setTimeout(() => { - dispatch(removeTranscriptMessage(transcriptMessageID)); - }, REMOVE_AFTER_MS); + transcriptMessage.clearTimeOut + = setTimeout( + () => dispatch(removeTranscriptMessage(transcriptMessageID)), + REMOVE_AFTER_MS); }