diff --git a/bigbluebutton-html5/imports/api/captions/server/methods.js b/bigbluebutton-html5/imports/api/captions/server/methods.js index c1cb17675e72..64e8687f2a5f 100644 --- a/bigbluebutton-html5/imports/api/captions/server/methods.js +++ b/bigbluebutton-html5/imports/api/captions/server/methods.js @@ -3,10 +3,14 @@ import updateCaptionsOwner from '/imports/api/captions/server/methods/updateCapt import startDictation from '/imports/api/captions/server/methods/startDictation'; import stopDictation from '/imports/api/captions/server/methods/stopDictation'; import pushSpeechTranscript from '/imports/api/captions/server/methods/pushSpeechTranscript'; +import enableAutoTranslation from '/imports/api/captions/server/methods/enableAutoTranslation'; +import disableAutoTranslation from '/imports/api/captions/server/methods/disableAutoTranslation'; Meteor.methods({ updateCaptionsOwner, startDictation, stopDictation, pushSpeechTranscript, + enableAutoTranslation, + disableAutoTranslation, }); diff --git a/bigbluebutton-html5/imports/api/captions/server/methods/disableAutoTranslation.js b/bigbluebutton-html5/imports/api/captions/server/methods/disableAutoTranslation.js new file mode 100644 index 000000000000..df90c52b9c09 --- /dev/null +++ b/bigbluebutton-html5/imports/api/captions/server/methods/disableAutoTranslation.js @@ -0,0 +1,20 @@ +//Do we need this? It just modifies an entry of Captions -> seems yes +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; +import setAutoTranslation from '/imports/api/captions/server/modifiers/setAutoTranslation'; + +export default function disableAutoTranslation(locale) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(locale, String); + check(meetingId, String); + check(requesterUserId, String); + + //const caption = Captions.findOne({ meetingId, locale }, { fields: { translating:1 } }); + setAutoTranslation(meetingId, locale, false); + } catch (err) { + Logger.error(`Exception while invoking method disbleAutoTranslation ${err.stack}`); + } +} diff --git a/bigbluebutton-html5/imports/api/captions/server/methods/enableAutoTranslation.js b/bigbluebutton-html5/imports/api/captions/server/methods/enableAutoTranslation.js new file mode 100644 index 000000000000..52cddeb07da4 --- /dev/null +++ b/bigbluebutton-html5/imports/api/captions/server/methods/enableAutoTranslation.js @@ -0,0 +1,20 @@ +//Do we need this? It just modifies an entry of Captions -> seems yes +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; +import setAutoTranslation from '/imports/api/captions/server/modifiers/setAutoTranslation'; + +export default function enableAutoTranslation(locale) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(locale, String); + check(meetingId, String); + check(requesterUserId, String); + + //const caption = Captions.findOne({ meetingId, locale }, { fields: { translating:1 } }); + setAutoTranslation(meetingId, locale, true); + } catch (err) { + Logger.error(`Exception while invoking method enableAutoTranslation ${err.stack}`); + } +} diff --git a/bigbluebutton-html5/imports/api/captions/server/methods/pushSpeechTranscript.js b/bigbluebutton-html5/imports/api/captions/server/methods/pushSpeechTranscript.js index 7e81127fe6f2..6e7358df6063 100644 --- a/bigbluebutton-html5/imports/api/captions/server/methods/pushSpeechTranscript.js +++ b/bigbluebutton-html5/imports/api/captions/server/methods/pushSpeechTranscript.js @@ -1,11 +1,67 @@ import { check } from 'meteor/check'; import Captions from '/imports/api/captions'; +import Users from '/imports/api/users'; import { extractCredentials } from '/imports/api/common/server/helpers'; import Logger from '/imports/startup/server/logger'; import setTranscript from '/imports/api/captions/server/modifiers/setTranscript'; import updatePad from '/imports/api/pads/server/methods/updatePad'; +import axios from 'axios'; -export default function pushSpeechTranscript(locale, transcript, type) { +const CAPTIONS_CONFIG = Meteor.settings.public.captions; + +function sendTranscript(meetingId, requesterUserId, type, dst, text) { + const user = Users.findOne({meetingId, userId: requesterUserId}, { fields: {name: 1}}); + //console.log("sendTranscript", text); + const textWithName = `${user.name}: ${text}`; + if (type === 'final') { + const textLf = `\n${textWithName}`; + updatePad(meetingId, requesterUserId, dst, textLf); + } + setTranscript(meetingId, dst, textWithName); +} + +function translateText(meetingId, requesterUserId, textOri, type, src, dst) { + if ( !CAPTIONS_CONFIG.enableAutomaticTranslation || textOri === "" || !dst || dst === "" || dst === src || dst.replace(/-..$/,'') === src || dst === src.replace(/-..$/,'') ) { + sendTranscript(meetingId, requesterUserId, type, dst, textOri); + } else { + let url = ''; + if (CAPTIONS_CONFIG.googleTranslateUrl) { + url = CAPTIONS_CONFIG.googleTranslateUrl + '/exec?' + + 'text=' + encodeURIComponent(textOri) + '&source=' + src + '&target=' + dst; + } else if (CAPTIONS_CONFIG.deeplTranslateUrl) { + url = CAPTIONS_CONFIG.deeplTranslateUrl + + '&text=' + encodeURIComponent(textOri) + '&source_lang=' + src.replace(/-..$/,'').toUpperCase() + + '&target_lang=' + dst.toUpperCase(); + } else { + Logger.error('Could not get a translation service.'); + return; + } + + axios({ + method: 'get', + url, + responseType: 'json', + }).then((response) => { + if (CAPTIONS_CONFIG.googleTranslateUrl) { + const { code, text } = response.data; + if (code === 200) { + sendTranscript(meetingId, requesterUserId, type, dst, text); + } else { + Logger.error(`Failed to get Google translation for ${textOri}`); + } + } else if (CAPTIONS_CONFIG.deeplTranslateUrl) { + const { translations } = response.data; + if (translations.length > 0 && translations[0].text) { + sendTranscript(meetingId, requesterUserId, type, dst, translations[0].text); + } else { + Logger.error(`Failed to get DeepL translation for ${textOri}`); + } + } + }).catch((error) => Logger.error(`Could not get translation for ${textOri.trim()} on the locale ${dst}: ${error}`)); + } +} + +export default function pushSpeechTranscript(locale, transcript, type, locales) { try { const { meetingId, requesterUserId } = extractCredentials(this.userId); @@ -14,22 +70,20 @@ export default function pushSpeechTranscript(locale, transcript, type) { check(locale, String); check(transcript, String); check(type, String); + check(locales, Array); - const captions = Captions.findOne({ - meetingId, - ownerId: requesterUserId, - locale, - dictating: true, - }); + locales.forEach(function(dstLocale, index) { + const caption = Captions.findOne({ + meetingId, + locale: dstLocale, + }); - if (captions) { - if (type === 'final') { - const text = `\n${transcript}`; - updatePad(meetingId, requesterUserId, locale, text); + if (!caption) { + Logger.error(`Could not find the caption's pad for meetingId=${meetingId} locale=${locale}`); + } else { + translateText(meetingId, requesterUserId, transcript, type, locale, dstLocale); } - - setTranscript(meetingId, locale, transcript); - } + }); } catch (err) { Logger.error(`Exception while invoking method pushSpeechTranscript ${err.stack}`); } diff --git a/bigbluebutton-html5/imports/api/captions/server/methods/startDictation.js b/bigbluebutton-html5/imports/api/captions/server/methods/startDictation.js index 7d0baa2a9df9..eef995558144 100644 --- a/bigbluebutton-html5/imports/api/captions/server/methods/startDictation.js +++ b/bigbluebutton-html5/imports/api/captions/server/methods/startDictation.js @@ -14,7 +14,6 @@ export default function startDictation(locale) { const captions = Captions.findOne({ meetingId, - ownerId: requesterUserId, locale, }); diff --git a/bigbluebutton-html5/imports/api/captions/server/methods/stopDictation.js b/bigbluebutton-html5/imports/api/captions/server/methods/stopDictation.js index 44e469348ad1..7f8078dfdbcf 100644 --- a/bigbluebutton-html5/imports/api/captions/server/methods/stopDictation.js +++ b/bigbluebutton-html5/imports/api/captions/server/methods/stopDictation.js @@ -14,7 +14,6 @@ export default function stopDictation(locale) { const captions = Captions.findOne({ meetingId, - ownerId: requesterUserId, locale, }); diff --git a/bigbluebutton-html5/imports/api/captions/server/modifiers/createCaptions.js b/bigbluebutton-html5/imports/api/captions/server/modifiers/createCaptions.js index 382be4726615..f92b3ea8fd58 100644 --- a/bigbluebutton-html5/imports/api/captions/server/modifiers/createCaptions.js +++ b/bigbluebutton-html5/imports/api/captions/server/modifiers/createCaptions.js @@ -20,6 +20,9 @@ export default function createCaptions(meetingId, locale, name) { ownerId: '', dictating: false, transcript: '', + translating: false, + translationDoner: {}, + speechDoner: {}, }; const numberAffected = Captions.upsert(selector, modifier); diff --git a/bigbluebutton-html5/imports/api/captions/server/modifiers/setAutoTranslation.js b/bigbluebutton-html5/imports/api/captions/server/modifiers/setAutoTranslation.js new file mode 100644 index 000000000000..d13d769568ff --- /dev/null +++ b/bigbluebutton-html5/imports/api/captions/server/modifiers/setAutoTranslation.js @@ -0,0 +1,31 @@ +//Do we need this? It just sets "translating" -> seems yes +import Captions from '/imports/api/captions'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default function setAutoTranslation(meetingId, locale, translating) { + check(meetingId, String); + check(locale, String); + check(translating, Boolean); + + const selector = { + meetingId, + locale, + }; + + const modifier = { + $set: { + translating, + } + }; + + try { + const numberAffected = Captions.update(selector, modifier); + + if (numberAffected) { + Logger.verbose('Captions: updated pad autoTranslation', { locale }); + } + } catch (err) { + Logger.error(`Updating captions pad autoTranslation: ${err}`); + } +} diff --git a/bigbluebutton-html5/imports/api/captions/server/modifiers/updateCaptionsOwner.js b/bigbluebutton-html5/imports/api/captions/server/modifiers/updateCaptionsOwner.js index 61595c886f9f..e4a10524920f 100644 --- a/bigbluebutton-html5/imports/api/captions/server/modifiers/updateCaptionsOwner.js +++ b/bigbluebutton-html5/imports/api/captions/server/modifiers/updateCaptionsOwner.js @@ -16,7 +16,6 @@ export default function updateCaptionsOwner(meetingId, locale, ownerId) { const modifier = { $set: { ownerId, - dictating: false, // Refresh dictation mode }, }; diff --git a/bigbluebutton-html5/imports/ui/components/captions/button/component.jsx b/bigbluebutton-html5/imports/ui/components/captions/button/component.jsx index 19de6750f15a..a46f2080535f 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/button/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/captions/button/component.jsx @@ -2,6 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; import Styled from './styles'; +import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji'; +import _ from 'lodash'; +import Dropdown from '/imports/ui/components/dropdown/component'; +import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component'; +import DropdownContent from '/imports/ui/components/dropdown/content/component'; +import DropdownList from '/imports/ui/components/dropdown/list/component'; +import DropdownListItem from '/imports/ui/components/dropdown/list/item/component'; +import Service from '/imports/ui/components/captions/service'; const propTypes = { intl: PropTypes.shape({ @@ -20,20 +28,61 @@ const intlMessages = defineMessages({ id: 'app.actionsBar.captions.stop', description: 'Stop closed captions option', }, + changeCaption: { + id: 'app.actionsBar.changeCaption', + description: 'Open change caption label', + }, }); -const CaptionsButton = ({ intl, isActive, handleOnClick }) => ( - +const handleClickCaption = (locale, selectedLocale) => { + if (selectedLocale != locale) { + Service.setCaptionsActive(locale); + } +} + +const getAvailableCaptions = (captions, selected) => { + return( + captions.map(locale => ( + handleClickCaption(locale.locale, selected)} + iconRight={selected == locale.locale ? 'check' : null} + /> + )) + ); +}; + +const CaptionsButton = ({ intl, isActive, handleOnClick, translatedLocales, selectedLocale }) => ( +
+ + {isActive && + + + + + + + {getAvailableCaptions(translatedLocales, selectedLocale)} + + + + } +
); CaptionsButton.propTypes = propTypes; diff --git a/bigbluebutton-html5/imports/ui/components/captions/button/container.jsx b/bigbluebutton-html5/imports/ui/components/captions/button/container.jsx index add5eb0a6673..064a8b410f03 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/button/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/captions/button/container.jsx @@ -12,4 +12,6 @@ export default withModalMounter(withTracker(({ mountModal }) => ({ handleOnClick: () => (Service.isCaptionsActive() ? Service.deactivateCaptions() : mountModal()), + translatedLocales: Service.getLocalesAutoTranslated(), + selectedLocale: Service.getCaptionsActive(), }))(Container)); diff --git a/bigbluebutton-html5/imports/ui/components/captions/component.jsx b/bigbluebutton-html5/imports/ui/components/captions/component.jsx index 3b5f4a5a82b5..18c523184c30 100644 --- a/bigbluebutton-html5/imports/ui/components/captions/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/captions/component.jsx @@ -9,6 +9,8 @@ import Styled from './styles'; import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums'; import browserInfo from '/imports/utils/browserInfo'; import Header from '/imports/ui/components/common/control-header/component'; +import StyledHeader from '/imports/ui/components/common/control-header//styles'; +import { components } from 'react-select'; const intlMessages = defineMessages({ hide: { @@ -39,37 +41,82 @@ const intlMessages = defineMessages({ id: 'app.captions.dictationOffDesc', description: 'Aria description for button that turns off speech recognition', }, + autoTranslation: { + id: 'app.captions.pad.autoTranslation', + description: 'Label for auto translation of closed captions pad', + }, + autoTranslationDesc: { + id: 'app.captions.pad.autoTranslationDesc', + description: 'Descriotion for auto translation of closed captions pad', + }, }); const propTypes = { locale: PropTypes.string.isRequired, name: PropTypes.string.isRequired, - ownerId: PropTypes.string.isRequired, intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired, }).isRequired, - dictation: PropTypes.bool.isRequired, - dictating: PropTypes.bool.isRequired, isRTL: PropTypes.bool.isRequired, hasPermission: PropTypes.bool.isRequired, layoutContextDispatch: PropTypes.func.isRequired, isResizing: PropTypes.bool.isRequired, }; +const MultiValueRemove = (props) => { + if (props.data.isFixed) { + return null; + } + return ; +}; + const Captions = ({ locale, intl, - ownerId, name, - dictation, - dictating, + amISpeaker, isRTL, hasPermission, layoutContextDispatch, isResizing, + isAutoTranslated, + //toggleAutoTranslation, }) => { const { isChrome } = browserInfo; + const localeOptions = []; + const selectedLocales = []; + Service.getAvailableLocales().forEach((loc) => { + //The current locale not included + localeOptions.push({value: loc.locale, label: loc.name}); + if (loc.translating) { + if (loc.locale == locale) { + selectedLocales.push({value: loc.locale, label: loc.name, isFixed: true}); + } else { + selectedLocales.push({value: loc.locale, label: loc.name}); + } + } + }); + + const onTranslationLocaleChanged = ( + newValue, + actionMeta + ) => { + switch (actionMeta.action) { + case 'remove-value': + case 'pop-value': + Service.removeTranslation(actionMeta.removedValue.value); + break; + case 'select-option': + Service.selectTranslation(actionMeta.option.value); + break; + case 'clear': + //This would't happen.. + Service.clearTranslation(); + break; + } + }; + return (