This commit is contained in:
72
react/features/subtitles/actionTypes.ts
Normal file
72
react/features/subtitles/actionTypes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 = 'REMOVE_TRANSCRIPT_MESSAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which indicates that an cached transcript
|
||||
* has to be removed from the state.
|
||||
*
|
||||
* {
|
||||
* type: REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
* transciptMessageID: string,
|
||||
* }
|
||||
*/
|
||||
export const REMOVE_CACHED_TRANSCRIPT_MESSAGE = 'REMOVE_CACHED_TRANSCRIPT_MESSAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which indicates that a transcript with an
|
||||
* given message_id to be added or updated is received.
|
||||
*
|
||||
* {
|
||||
* type: UPDATE_TRANSCRIPT_MESSAGE,
|
||||
* transcriptMessageID: string,
|
||||
* newTranscriptMessage: Object
|
||||
* }
|
||||
*/
|
||||
export const UPDATE_TRANSCRIPT_MESSAGE = 'UPDATE_TRANSCRIPT_MESSAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which indicates that the user pressed the
|
||||
* ClosedCaption button, to either enable or disable subtitles based on the
|
||||
* current state.
|
||||
*
|
||||
* {
|
||||
* type: TOGGLE_REQUESTING_SUBTITLES
|
||||
* }
|
||||
*/
|
||||
export const TOGGLE_REQUESTING_SUBTITLES
|
||||
= 'TOGGLE_REQUESTING_SUBTITLES';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which indicates if the user set the state of
|
||||
* the subtitles to enabled or disabled.
|
||||
*
|
||||
* {
|
||||
* type: SET_REQUESTING_SUBTITLES
|
||||
* enabled: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_REQUESTING_SUBTITLES
|
||||
= 'SET_REQUESTING_SUBTITLES';
|
||||
|
||||
/**
|
||||
* Action to store received subtitles in history.
|
||||
*/
|
||||
export const STORE_SUBTITLE = 'STORE_SUBTITLE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which indicates that an error occurred while starting subtitles.
|
||||
*
|
||||
* {
|
||||
* type: SET_SUBTITLES_ERROR,
|
||||
* hasError: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_SUBTITLES_ERROR = 'SET_SUBTITLES_ERROR';
|
||||
139
react/features/subtitles/actions.any.ts
Normal file
139
react/features/subtitles/actions.any.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { DEFAULT_LANGUAGE } from '../base/i18n/i18next';
|
||||
|
||||
import {
|
||||
REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
REMOVE_TRANSCRIPT_MESSAGE,
|
||||
SET_REQUESTING_SUBTITLES,
|
||||
SET_SUBTITLES_ERROR,
|
||||
STORE_SUBTITLE,
|
||||
TOGGLE_REQUESTING_SUBTITLES,
|
||||
UPDATE_TRANSCRIPT_MESSAGE
|
||||
} from './actionTypes';
|
||||
import { ISubtitle } from './types';
|
||||
|
||||
/**
|
||||
* 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 cached transcript has to be removed from the state.
|
||||
*
|
||||
* @param {string} transcriptMessageID - The message_id to be removed.
|
||||
* @returns {{
|
||||
* type: REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
* transcriptMessageID: string,
|
||||
* }}
|
||||
*/
|
||||
export function removeCachedTranscriptMessage(transcriptMessageID: string) {
|
||||
return {
|
||||
type: REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
transcriptMessageID
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that a transcript with the given message_id to be added or 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the local user has toggled the ClosedCaption button.
|
||||
*
|
||||
* @returns {{
|
||||
* type: TOGGLE_REQUESTING_SUBTITLES
|
||||
* }}
|
||||
*/
|
||||
export function toggleRequestingSubtitles() {
|
||||
return {
|
||||
type: TOGGLE_REQUESTING_SUBTITLES
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the local user has enabled or disabled the subtitles.
|
||||
*
|
||||
* @param {boolean} enabled - The new state of the subtitles.
|
||||
* @param {boolean} displaySubtitles - Whether to display subtitles or not.
|
||||
* @param {string} language - The language of the subtitles.
|
||||
* @param {boolean} backendRecordingOn - Whether backend recording is on.
|
||||
* @returns {{
|
||||
* type: SET_REQUESTING_SUBTITLES,
|
||||
* backendRecordingOn: boolean,
|
||||
* enabled: boolean,
|
||||
* displaySubtitles: boolean,
|
||||
* language: string
|
||||
* }}
|
||||
*/
|
||||
export function setRequestingSubtitles(
|
||||
enabled: boolean,
|
||||
displaySubtitles = true,
|
||||
language: string | null = `translation-languages:${DEFAULT_LANGUAGE}`,
|
||||
backendRecordingOn = false) {
|
||||
return {
|
||||
type: SET_REQUESTING_SUBTITLES,
|
||||
backendRecordingOn,
|
||||
displaySubtitles,
|
||||
enabled,
|
||||
language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a received subtitle in the history.
|
||||
*
|
||||
* @param {ISubtitle} subtitle - The subtitle to store.
|
||||
* @returns {{
|
||||
* type: STORE_SUBTITLE,
|
||||
* subtitle: ISubtitle
|
||||
* }}
|
||||
*/
|
||||
export function storeSubtitle(subtitle: ISubtitle) {
|
||||
return {
|
||||
type: STORE_SUBTITLE,
|
||||
subtitle
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that an error occurred while starting subtitles.
|
||||
*
|
||||
* @param {boolean} hasError - Whether an error occurred or not.
|
||||
* @returns {{
|
||||
* type: SET_SUBTITLES_ERROR,
|
||||
* hasError: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setSubtitlesError(hasError: boolean) {
|
||||
return {
|
||||
type: SET_SUBTITLES_ERROR,
|
||||
hasError
|
||||
};
|
||||
}
|
||||
1
react/features/subtitles/actions.native.ts
Normal file
1
react/features/subtitles/actions.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './actions.any';
|
||||
17
react/features/subtitles/actions.web.ts
Normal file
17
react/features/subtitles/actions.web.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { toggleDialog } from '../base/dialog/actions';
|
||||
|
||||
import LanguageSelectorDialog from './components/web/LanguageSelectorDialog';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Signals that the local user has toggled the LanguageSelector button.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function toggleLanguageSelectorDialog() {
|
||||
return function(dispatch: IStore['dispatch']) {
|
||||
dispatch(toggleDialog(LanguageSelectorDialog));
|
||||
};
|
||||
}
|
||||
143
react/features/subtitles/components/AbstractCaptions.tsx
Normal file
143
react/features/subtitles/components/AbstractCaptions.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { Component, ReactElement } from 'react';
|
||||
|
||||
import { IReduxState } from '../../app/types';
|
||||
|
||||
|
||||
/**
|
||||
* {@code AbstractCaptions} Properties.
|
||||
*/
|
||||
export interface IAbstractCaptionsProps {
|
||||
|
||||
/**
|
||||
* Whether local participant is displaying subtitles.
|
||||
*/
|
||||
_displaySubtitles: boolean;
|
||||
|
||||
/**
|
||||
* Whether local participant is requesting 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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract React {@code Component} which can display speech-to-text results
|
||||
* from Jigasi as subtitles.
|
||||
*/
|
||||
export class AbstractCaptions<P extends IAbstractCaptionsProps> extends Component<P> {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render(): any {
|
||||
const { _displaySubtitles, _requestingSubtitles, _transcripts } = this.props;
|
||||
|
||||
if (!_requestingSubtitles || !_displaySubtitles || !_transcripts || !_transcripts.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paragraphs = [];
|
||||
|
||||
// @ts-ignore
|
||||
for (const [ id, text ] of _transcripts ?? []) {
|
||||
paragraphs.push(this._renderParagraph(id, text));
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
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 {ReactElement} - The React element which displays the text.
|
||||
*/
|
||||
_renderParagraph(_id: string, _text: string) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the subtitles container.
|
||||
*
|
||||
* @abstract
|
||||
* @param {Array<ReactElement>} _el - An array of elements created
|
||||
* for each subtitle using the {@link _renderParagraph} method.
|
||||
* @protected
|
||||
* @returns {ReactElement} - The subtitles container.
|
||||
*/
|
||||
_renderSubtitlesContainer(_el: Array<ReactElement>) {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>} - Formatted transcript subtitles mapped by
|
||||
* transcript message IDs.
|
||||
*/
|
||||
function _constructTranscripts(state: IReduxState): Map<string, string> {
|
||||
const { _transcriptMessages } = state['features/subtitles'];
|
||||
const transcripts = new Map();
|
||||
|
||||
for (const [ id, transcriptMessage ] of _transcriptMessages) {
|
||||
if (transcriptMessage) {
|
||||
let text = `${transcriptMessage.participant.name}: `;
|
||||
|
||||
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<string, string>
|
||||
* }}
|
||||
*/
|
||||
export function _abstractMapStateToProps(state: IReduxState) {
|
||||
const { _displaySubtitles, _requestingSubtitles } = state['features/subtitles'];
|
||||
const transcripts = _constructTranscripts(state);
|
||||
|
||||
return {
|
||||
_displaySubtitles,
|
||||
_requestingSubtitles,
|
||||
|
||||
// avoid re-renders by setting to prop new empty Map instances.
|
||||
_transcripts: transcripts.size === 0 ? undefined : transcripts
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { MEET_FEATURES } from '../../base/jwt/constants';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../jaas/actions';
|
||||
import { canStartSubtitles, isCCTabEnabled } from '../functions.any';
|
||||
|
||||
/**
|
||||
* Props interface for the Abstract Closed Caption Button component.
|
||||
*
|
||||
* @interface IAbstractProps
|
||||
* @augments {AbstractButtonProps}
|
||||
*/
|
||||
export interface IAbstractProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* Whether the subtitles tab is enabled in the UI.
|
||||
*/
|
||||
_isCCTabEnabled: boolean;
|
||||
|
||||
_language: string | null;
|
||||
|
||||
/**
|
||||
* Whether the local participant is currently requesting subtitles.
|
||||
*/
|
||||
_requestingSubtitles: boolean;
|
||||
|
||||
/**
|
||||
* Selected language for subtitle.
|
||||
*/
|
||||
_subtitles: string;
|
||||
|
||||
languages?: string;
|
||||
|
||||
languagesHead?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The button component which starts/stops the transcription.
|
||||
*/
|
||||
export class AbstractClosedCaptionButton
|
||||
extends AbstractButton<IAbstractProps> {
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which should be used
|
||||
* to handle the closed caption button being clicked / pressed.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClickOpenLanguageSelector() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { _requestingSubtitles, dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent('transcribing.ccButton',
|
||||
{
|
||||
'requesting_subtitles': Boolean(_requestingSubtitles)
|
||||
}));
|
||||
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
this._handleClickOpenLanguageSelector();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is disabled or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isDisabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in toggled state or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return this.props._requestingSubtitles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code AbstractClosedCaptionButton} component.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The properties explicitly passed to the component
|
||||
* instance.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _requestingSubtitles: boolean,
|
||||
* _language: string,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _abstractMapStateToProps(state: IReduxState, ownProps: IAbstractProps) {
|
||||
const { _requestingSubtitles, _language } = state['features/subtitles'];
|
||||
|
||||
// if the participant is moderator, it can enable transcriptions and if
|
||||
// transcriptions are already started for the meeting, guests can just show them
|
||||
const { visible = canStartSubtitles(state) } = ownProps;
|
||||
|
||||
return {
|
||||
_isCCTabEnabled: isCCTabEnabled(state),
|
||||
_requestingSubtitles,
|
||||
_language,
|
||||
visible
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { ComponentType, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { setRequestingSubtitles } from '../actions.any';
|
||||
import { getAvailableSubtitlesLanguages } from '../functions.any';
|
||||
|
||||
|
||||
export interface IAbstractLanguageSelectorDialogProps {
|
||||
dispatch: IStore['dispatch'];
|
||||
language: string | null;
|
||||
listItems: Array<any>;
|
||||
onLanguageSelected: (e: string) => void;
|
||||
subtitles: string;
|
||||
t: Function;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Higher Order Component taking in a concrete LanguageSelector component and
|
||||
* augmenting it with state/behavior common to both web and native implementations.
|
||||
*
|
||||
* @param {React.Component} Component - The concrete component.
|
||||
* @returns {React.Component}
|
||||
*/
|
||||
const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLanguageSelectorDialogProps>) => () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const language = useSelector((state: IReduxState) => state['features/subtitles']._language);
|
||||
|
||||
// The value for the selected language contains "translation-languages:" prefix.
|
||||
const selectedLanguage = language?.replace('translation-languages:', '');
|
||||
const languageCodes = useSelector((state: IReduxState) => getAvailableSubtitlesLanguages(state, selectedLanguage));
|
||||
|
||||
const noLanguageLabel = 'transcribing.subtitlesOff';
|
||||
const selected = language ?? noLanguageLabel;
|
||||
const items = [ noLanguageLabel, ...languageCodes.map((lang: string) => `translation-languages:${lang}`) ];
|
||||
const listItems = items
|
||||
.map((lang, index) => {
|
||||
return {
|
||||
id: lang + index,
|
||||
lang,
|
||||
selected: lang === selected
|
||||
};
|
||||
});
|
||||
|
||||
const onLanguageSelected = useCallback((value: string) => {
|
||||
const _selectedLanguage = value === noLanguageLabel ? null : value;
|
||||
const enabled = Boolean(_selectedLanguage);
|
||||
const displaySubtitles = enabled;
|
||||
|
||||
dispatch(setRequestingSubtitles(enabled, displaySubtitles, _selectedLanguage));
|
||||
}, [ language ]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
dispatch = { dispatch }
|
||||
language = { language }
|
||||
listItems = { listItems }
|
||||
onLanguageSelected = { onLanguageSelected }
|
||||
subtitles = { selected }
|
||||
t = { t } />
|
||||
);
|
||||
};
|
||||
|
||||
export default AbstractLanguageSelectorDialog;
|
||||
65
react/features/subtitles/components/native/Captions.tsx
Normal file
65
react/features/subtitles/components/native/Captions.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { GestureResponderEvent, StyleProp } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Container from '../../../base/react/components/native/Container';
|
||||
import Text from '../../../base/react/components/native/Text';
|
||||
import {
|
||||
AbstractCaptions,
|
||||
type IAbstractCaptionsProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractCaptions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link Captions}.
|
||||
*/
|
||||
interface IProps extends IAbstractCaptionsProps {
|
||||
onPress: (event: GestureResponderEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React {@code Component} which can display speech-to-text results from
|
||||
* Jigasi as subtitles.
|
||||
*/
|
||||
class Captions extends AbstractCaptions<IProps> {
|
||||
/**
|
||||
* 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 {ReactElement} - The React element which displays the text.
|
||||
*/
|
||||
_renderParagraph(id: string, text: string): ReactElement {
|
||||
return (
|
||||
<Text
|
||||
key = { id }
|
||||
onPress = { this.props.onPress }
|
||||
style = { styles.captionsSubtitles as StyleProp<Object> } >
|
||||
{ text }
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the subtitles container.
|
||||
*
|
||||
* @param {Array<ReactElement>} paragraphs - An array of elements created
|
||||
* for each subtitle using the {@link _renderParagraph} method.
|
||||
* @protected
|
||||
* @returns {ReactElement} - The subtitles container.
|
||||
*/
|
||||
_renderSubtitlesContainer(paragraphs: Array<ReactElement>): ReactElement {
|
||||
return (
|
||||
<Container style = { styles.captionsSubtitlesContainer } >
|
||||
{ paragraphs }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(_abstractMapStateToProps)(Captions);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { CLOSE_CAPTIONS_ENABLED } from '../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../base/flags/functions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconSubtitles } from '../../../base/icons/svg';
|
||||
import { navigate }
|
||||
from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import {
|
||||
AbstractClosedCaptionButton,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractClosedCaptionButton';
|
||||
|
||||
/**
|
||||
* A button which starts/stops the transcriptions.
|
||||
*/
|
||||
class ClosedCaptionButton
|
||||
extends AbstractClosedCaptionButton {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.cc';
|
||||
override icon = IconSubtitles;
|
||||
override label = 'toolbar.startSubtitles';
|
||||
labelProps = {
|
||||
language: this.props.t(this.props._language ?? 'transcribing.subtitlesOff'),
|
||||
languages: this.props.t(this.props.languages ?? ''),
|
||||
languagesHead: this.props.t(this.props.languagesHead ?? '')
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle language selection dialog.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClickOpenLanguageSelector() {
|
||||
navigate(screen.conference.subtitles);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The properties explicitly passed to the component
|
||||
* instance.
|
||||
* @private
|
||||
* @returns {Props}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const enabled = getFeatureFlag(state, CLOSE_CAPTIONS_ENABLED, true);
|
||||
const abstractProps = _abstractMapStateToProps(state, ownProps);
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
visible: abstractProps.visible && enabled
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(ClosedCaptionButton));
|
||||
43
react/features/subtitles/components/native/LanguageList.tsx
Normal file
43
react/features/subtitles/components/native/LanguageList.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
|
||||
import LanguageListItem from './LanguageListItem';
|
||||
import styles from './styles';
|
||||
|
||||
interface ILanguageListProps {
|
||||
items: Array<ILanguageItem>;
|
||||
onLanguageSelected: (lang: string) => void;
|
||||
selectedLanguage: string;
|
||||
}
|
||||
|
||||
interface ILanguageItem {
|
||||
id: string;
|
||||
lang: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders the security options dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const LanguageList = ({ items, onLanguageSelected }: ILanguageListProps) => {
|
||||
|
||||
const listItems = items?.map(item => (
|
||||
<LanguageListItem
|
||||
key = { item.id }
|
||||
lang = { item.lang }
|
||||
onLanguageSelected = { onLanguageSelected }
|
||||
selected = { item.selected } />
|
||||
));
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
bounces = { false }
|
||||
style = { styles.itemsContainer }>
|
||||
{ listItems }
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageList;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { StyleProp, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCheck } from '../../../base/icons/svg';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
|
||||
interface ILanguageListItemProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Language string.
|
||||
*/
|
||||
lang: string;
|
||||
|
||||
/**
|
||||
* Callback for language selection.
|
||||
*/
|
||||
onLanguageSelected: (lang: string) => void;
|
||||
|
||||
/**
|
||||
* If language item is selected or not.
|
||||
*/
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders the language list item.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
|
||||
const LanguageListItem = ({ t, lang, selected, onLanguageSelected
|
||||
}: ILanguageListItemProps) => {
|
||||
|
||||
const onLanguageSelectedWrapper
|
||||
= useCallback(() => onLanguageSelected(lang), [ lang ]);
|
||||
|
||||
return (
|
||||
<View style = { styles.languageItemWrapper as StyleProp<ViewStyle> }>
|
||||
<View style = { styles.iconWrapper }>
|
||||
{
|
||||
selected
|
||||
&& <Icon
|
||||
size = { 20 }
|
||||
src = { IconCheck } />
|
||||
}
|
||||
</View>
|
||||
<TouchableHighlight
|
||||
onPress = { onLanguageSelectedWrapper }
|
||||
underlayColor = { 'transparent' } >
|
||||
<Text
|
||||
style = { [
|
||||
styles.languageItemText,
|
||||
selected && styles.activeLanguageItemText ] }>
|
||||
{ t(lang) }
|
||||
</Text>
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(LanguageListItem);
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { goBack }
|
||||
from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import AbstractLanguageSelectorDialog, {
|
||||
IAbstractLanguageSelectorDialogProps
|
||||
} from '../AbstractLanguageSelectorDialog';
|
||||
|
||||
import LanguageList from './LanguageList';
|
||||
import styles from './styles';
|
||||
|
||||
const LanguageSelectorDialog = (props: IAbstractLanguageSelectorDialogProps) => {
|
||||
const { language, listItems, onLanguageSelected, subtitles } = props;
|
||||
|
||||
const onSelected = useCallback((e: string) => {
|
||||
onLanguageSelected(e);
|
||||
goBack();
|
||||
}, [ language ]);
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
disableForcedKeyboardDismiss = { true }
|
||||
style = { styles.subtitlesContainer }>
|
||||
<LanguageList
|
||||
items = { listItems }
|
||||
onLanguageSelected = { onSelected }
|
||||
selectedLanguage = { subtitles } />
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractLanguageSelector to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractLanguageSelectorDialog(LanguageSelectorDialog);
|
||||
63
react/features/subtitles/components/native/styles.ts
Normal file
63
react/features/subtitles/components/native/styles.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { BoxModel } from '../../../base/styles/components/styles/BoxModel';
|
||||
import {
|
||||
ColorPalette
|
||||
} from '../../../base/styles/components/styles/ColorPalette';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Component}s of the feature subtitles.
|
||||
*/
|
||||
export default {
|
||||
languageItemWrapper: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
iconWrapper: {
|
||||
width: 32
|
||||
},
|
||||
|
||||
activeLanguageItemText: {
|
||||
...BaseTheme.typography.bodyShortBoldLarge
|
||||
},
|
||||
|
||||
languageItemText: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginLeft: BaseTheme.spacing[2],
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
subtitlesContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for subtitle paragraph.
|
||||
*/
|
||||
captionsSubtitles: {
|
||||
backgroundColor: ColorPalette.black,
|
||||
borderRadius: BoxModel.margin / 4,
|
||||
color: ColorPalette.white,
|
||||
marginBottom: BoxModel.margin,
|
||||
padding: BoxModel.padding / 2
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the subtitles container.
|
||||
*/
|
||||
captionsSubtitlesContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 0,
|
||||
justifyContent: 'flex-end',
|
||||
margin: BoxModel.margin
|
||||
},
|
||||
|
||||
itemsContainer: {
|
||||
marginHorizontal: BaseTheme.spacing[4],
|
||||
marginVertical: BaseTheme.spacing[4]
|
||||
}
|
||||
};
|
||||
169
react/features/subtitles/components/web/Captions.tsx
Normal file
169
react/features/subtitles/components/web/Captions.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { getVideospaceFloatingElementsBottomSpacing, pixelsToRem, remToPixels } from '../../../base/ui/functions.web';
|
||||
import { getStageParticipantNameLabelHeight } from '../../../display-name/components/web/styles';
|
||||
import { shouldDisplayStageParticipantBadge } from '../../../display-name/functions';
|
||||
import {
|
||||
getTransitionParamsForElementsAboveToolbox,
|
||||
isToolboxVisible,
|
||||
toCSSTransitionValue
|
||||
} from '../../../toolbox/functions.web';
|
||||
import { calculateSubtitlesFontSize } from '../../functions.web';
|
||||
import {
|
||||
AbstractCaptions,
|
||||
type IAbstractCaptionsProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractCaptions';
|
||||
|
||||
interface IProps extends IAbstractCaptionsProps {
|
||||
|
||||
/**
|
||||
* The height of the visible area.
|
||||
*/
|
||||
_clientHeight?: number;
|
||||
|
||||
/**
|
||||
* Whether the subtitles container is lifted above the invite box.
|
||||
*/
|
||||
_isLifted: boolean | undefined;
|
||||
|
||||
/**
|
||||
* Whether toolbar is shifted up or not.
|
||||
*/
|
||||
_shiftUp: boolean;
|
||||
|
||||
/**
|
||||
* Whether the toolbox is visible or not.
|
||||
*/
|
||||
_toolboxVisible: boolean;
|
||||
|
||||
/**
|
||||
* An object containing the CSS classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
}
|
||||
|
||||
|
||||
const styles = (theme: Theme, props: IProps) => {
|
||||
const { _isLifted = false, _clientHeight, _shiftUp = false, _toolboxVisible = false } = props;
|
||||
const fontSize = calculateSubtitlesFontSize(_clientHeight);
|
||||
|
||||
// Normally we would use 0.2 * fontSize in order to cover the background gap from line-height: 1.2 but it seems
|
||||
// the current font is a little bit larger than it is supposed to be.
|
||||
const padding = 0.1 * fontSize;
|
||||
let bottom = getVideospaceFloatingElementsBottomSpacing(theme, _toolboxVisible);
|
||||
let marginBottom = 0;
|
||||
|
||||
// This is the case where we display the onstage participant display name
|
||||
// below the subtitles.
|
||||
if (_isLifted) {
|
||||
// 10px is the space between the onstage participant display name label and subtitles. We also need
|
||||
// to add the padding of the subtitles because it will decrease the gap between the label and subtitles.
|
||||
bottom += remToPixels(getStageParticipantNameLabelHeight(theme, _clientHeight)) + 10 + padding;
|
||||
}
|
||||
|
||||
if (_shiftUp) {
|
||||
// The toolbar is shifted up with 30px from the css.
|
||||
marginBottom += 30;
|
||||
}
|
||||
|
||||
return {
|
||||
transcriptionSubtitles: {
|
||||
bottom: `${bottom}px`,
|
||||
marginBottom: `${marginBottom}px`,
|
||||
fontSize: pixelsToRem(fontSize),
|
||||
left: '50%',
|
||||
maxWidth: '50vw',
|
||||
overflowWrap: 'break-word' as const,
|
||||
pointerEvents: 'none' as const,
|
||||
position: 'absolute' as const,
|
||||
textShadow: `
|
||||
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)`,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 7, // The popups are with z-index 8. This z-index has to be lower.
|
||||
lineHeight: 1.2,
|
||||
transition: `bottom ${toCSSTransitionValue(getTransitionParamsForElementsAboveToolbox(_toolboxVisible))}`,
|
||||
|
||||
span: {
|
||||
color: '#fff',
|
||||
background: 'black',
|
||||
|
||||
// without this when the text is wrapped on 2+ lines there will be a gap in the background:
|
||||
padding: `${padding}px 8px`,
|
||||
boxDecorationBreak: 'clone' as const
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} which can display speech-to-text results from
|
||||
* Jigasi as subtitles.
|
||||
*/
|
||||
class Captions extends AbstractCaptions<IProps> {
|
||||
|
||||
/**
|
||||
* 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 {ReactElement} - The React element which displays the text.
|
||||
*/
|
||||
override _renderParagraph(id: string, text: string): ReactElement {
|
||||
return (
|
||||
<p key = { id }>
|
||||
<span>{ text }</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the subtitles container.
|
||||
*
|
||||
* @param {Array<ReactElement>} paragraphs - An array of elements created
|
||||
* for each subtitle using the {@link _renderParagraph} method.
|
||||
* @protected
|
||||
* @returns {ReactElement} - The subtitles container.
|
||||
*/
|
||||
override _renderSubtitlesContainer(paragraphs: Array<ReactElement>): ReactElement {
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<div className = { classes.transcriptionSubtitles } >
|
||||
{ paragraphs }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated {@code }'s
|
||||
* props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { clientHeight } = state['features/base/responsive-ui'];
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
_isLifted: shouldDisplayStageParticipantBadge(state),
|
||||
_clientHeight: clientHeight,
|
||||
_shiftUp: state['features/toolbox'].shiftUp,
|
||||
_toolboxVisible: isToolboxVisible(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(withStyles(Captions, styles));
|
||||
@@ -0,0 +1,92 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconSubtitles } from '../../../base/icons/svg';
|
||||
import { openCCPanel } from '../../../chat/actions.any';
|
||||
import { toggleLanguageSelectorDialog } from '../../actions.web';
|
||||
import { canStartSubtitles, isCCTabEnabled } from '../../functions.any';
|
||||
import {
|
||||
AbstractClosedCaptionButton,
|
||||
IAbstractProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractClosedCaptionButton';
|
||||
|
||||
/**
|
||||
* A button which starts/stops the transcriptions.
|
||||
*/
|
||||
class ClosedCaptionButton
|
||||
extends AbstractClosedCaptionButton {
|
||||
override icon = IconSubtitles;
|
||||
override labelProps = {
|
||||
language: this.props.t(this.props._language ?? 'transcribing.subtitlesOff'),
|
||||
languages: this.props.t(this.props.languages ?? ''),
|
||||
languagesHead: this.props.t(this.props.languagesHead ?? '')
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the current button label based on the CC tab state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _getLabel() {
|
||||
const { _isCCTabEnabled } = this.props;
|
||||
|
||||
return _isCCTabEnabled ? 'toolbar.closedCaptions' : 'toolbar.startSubtitles';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accessibility label for the button.
|
||||
*
|
||||
* @returns {string} Accessibility label.
|
||||
*/
|
||||
override _getAccessibilityLabel() {
|
||||
const { _isCCTabEnabled } = this.props;
|
||||
|
||||
return _isCCTabEnabled ? 'toolbar.accessibilityLabel.closedCaptions' : 'toolbar.accessibilityLabel.cc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tooltip text based on the CC tab state.
|
||||
*
|
||||
* @returns {string} The tooltip text.
|
||||
*/
|
||||
override _getTooltip() {
|
||||
const { _isCCTabEnabled } = this.props;
|
||||
|
||||
return _isCCTabEnabled ? 'transcribing.openClosedCaptions' : 'transcribing.ccButtonTooltip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle language selection dialog.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClickOpenLanguageSelector() {
|
||||
const { dispatch, _isCCTabEnabled } = this.props;
|
||||
|
||||
if (_isCCTabEnabled) {
|
||||
dispatch(openCCPanel());
|
||||
} else {
|
||||
dispatch(toggleLanguageSelectorDialog());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The component's own props.
|
||||
* @returns {Object} Mapped props for the component.
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState, ownProps: IAbstractProps) {
|
||||
const { visible = canStartSubtitles(state) || isCCTabEnabled(state) } = ownProps;
|
||||
|
||||
return _abstractMapStateToProps(state, {
|
||||
...ownProps,
|
||||
visible
|
||||
});
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(ClosedCaptionButton));
|
||||
52
react/features/subtitles/components/web/LanguageList.tsx
Normal file
52
react/features/subtitles/components/web/LanguageList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
|
||||
import LanguageListItem from './LanguageListItem';
|
||||
|
||||
interface ILanguageListProps {
|
||||
items: Array<ILanguageItem>;
|
||||
onLanguageSelected: (lang: string) => void;
|
||||
selectedLanguage: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
itemsContainer: {
|
||||
display: 'flex',
|
||||
flexFlow: 'column'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
interface ILanguageItem {
|
||||
id: string;
|
||||
lang: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders the security options dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const LanguageList = ({
|
||||
items,
|
||||
onLanguageSelected
|
||||
}: ILanguageListProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const listItems = items.map(item => (
|
||||
<LanguageListItem
|
||||
key = { item.id }
|
||||
lang = { item.lang }
|
||||
onLanguageSelected = { onLanguageSelected }
|
||||
selected = { item.selected } />
|
||||
));
|
||||
|
||||
return (
|
||||
<div className = { styles.itemsContainer }>{listItems}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageList;
|
||||
77
react/features/subtitles/components/web/LanguageListItem.tsx
Normal file
77
react/features/subtitles/components/web/LanguageListItem.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCheck } from '../../../base/icons/svg';
|
||||
|
||||
interface ILanguageListItemProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Language string.
|
||||
*/
|
||||
lang: string;
|
||||
|
||||
/**
|
||||
* Callback for language selection.
|
||||
*/
|
||||
onLanguageSelected: (lang: string) => void;
|
||||
|
||||
/**
|
||||
* If language item is selected or not.
|
||||
*/
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
itemContainer: {
|
||||
display: 'flex',
|
||||
color: theme.palette.text02,
|
||||
alignItems: 'center',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
padding: '5px 0',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui04
|
||||
}
|
||||
},
|
||||
iconWrapper: {
|
||||
margin: '4px 10px',
|
||||
width: '22px',
|
||||
height: '22px'
|
||||
},
|
||||
activeItemContainer: {
|
||||
fontWeight: 700
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders the language list item.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
|
||||
const LanguageListItem = ({
|
||||
t,
|
||||
lang,
|
||||
selected,
|
||||
onLanguageSelected
|
||||
}: ILanguageListItemProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const onLanguageSelectedWrapper = useCallback(() => onLanguageSelected(lang), [ lang ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { `${styles.itemContainer} ${selected ? styles.activeItemContainer : ''}` }
|
||||
onClick = { onLanguageSelectedWrapper }>
|
||||
<span className = { styles.iconWrapper }>{ selected
|
||||
&& <Icon src = { IconCheck } /> }</span>
|
||||
{ t(lang) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(LanguageListItem);
|
||||
103
react/features/subtitles/components/web/LanguageSelector.tsx
Normal file
103
react/features/subtitles/components/web/LanguageSelector.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { ChangeEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Select from '../../../base/ui/components/web/Select';
|
||||
import { setRequestingSubtitles } from '../../actions.any';
|
||||
import { getAvailableSubtitlesLanguages } from '../../functions.any';
|
||||
|
||||
/**
|
||||
* The styles for the LanguageSelector component.
|
||||
*
|
||||
* @param {Theme} theme - The MUI theme.
|
||||
* @returns {Object} The styles object.
|
||||
*/
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(2),
|
||||
gap: theme.spacing(2)
|
||||
},
|
||||
select: {
|
||||
flex: 1,
|
||||
minWidth: 200
|
||||
},
|
||||
label: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01,
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders a language selection dropdown.
|
||||
* Uses the same language options as LanguageSelectorDialog and
|
||||
* updates the subtitles language preference in Redux.
|
||||
*
|
||||
* @param {IProps} props - The component props.
|
||||
* @returns {JSX.Element} - The rendered component.
|
||||
*/
|
||||
function LanguageSelector() {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const selectedLanguage = useSelector((state: IReduxState) => state['features/subtitles']._language);
|
||||
const languageCodes = useSelector((state: IReduxState) => getAvailableSubtitlesLanguages(
|
||||
state,
|
||||
selectedLanguage?.replace('translation-languages:', '')
|
||||
));
|
||||
|
||||
/**
|
||||
* Maps available languages to Select component options format.
|
||||
*
|
||||
* @type {Array<{value: string, label: string}>}
|
||||
*/
|
||||
const languages = [ 'transcribing.original', ...languageCodes.map(lang => `translation-languages:${lang}`) ]
|
||||
.map(lang => {
|
||||
return {
|
||||
value: lang,
|
||||
label: t(lang)
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles language selection changes.
|
||||
* Dispatches the setRequestingSubtitles action with the new language.
|
||||
*
|
||||
* @param {string} value - The selected language code.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onLanguageChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||
let { value }: { value?: string | null; } = e.target;
|
||||
|
||||
if (value === 'transcribing.original') {
|
||||
value = null;
|
||||
}
|
||||
dispatch(setRequestingSubtitles(true, true, value));
|
||||
|
||||
if (value !== null) {
|
||||
value = value.replace('translation-languages:', '');
|
||||
}
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<span className = { classes.label }>
|
||||
{t('transcribing.translateTo')}:
|
||||
</span>
|
||||
<Select
|
||||
className = { classes.select }
|
||||
id = 'subtitles-language-select'
|
||||
onChange = { onLanguageChange }
|
||||
options = { languages }
|
||||
value = { selectedLanguage || 'transcribing.original' } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -0,0 +1,78 @@
|
||||
import i18next from 'i18next';
|
||||
import React, { useCallback } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { translate, translateToHTML } from '../../../base/i18n/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { openSettingsDialog } from '../../../settings/actions.web';
|
||||
import { SETTINGS_TABS } from '../../../settings/constants';
|
||||
import { toggleLanguageSelectorDialog } from '../../actions.web';
|
||||
import AbstractLanguageSelectorDialog, {
|
||||
IAbstractLanguageSelectorDialogProps
|
||||
} from '../AbstractLanguageSelectorDialog';
|
||||
|
||||
import LanguageList from './LanguageList';
|
||||
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
paragraphWrapper: {
|
||||
fontSize: '0.875rem',
|
||||
margin: '10px 0px',
|
||||
color: theme.palette.text01
|
||||
},
|
||||
spanWrapper: {
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
color: theme.palette.link01,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui04,
|
||||
color: theme.palette.link01Hover
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const LanguageSelectorDialog = (props: IAbstractLanguageSelectorDialogProps) => {
|
||||
const { dispatch, language, listItems, onLanguageSelected, subtitles, t } = props;
|
||||
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
const onSelected = useCallback((e: string) => {
|
||||
onLanguageSelected(e);
|
||||
dispatch(toggleLanguageSelectorDialog());
|
||||
}, [ language ]);
|
||||
|
||||
const onSourceLanguageClick = useCallback(() => {
|
||||
dispatch(openSettingsDialog(SETTINGS_TABS.MORE, false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
ok = {{ hidden: true }}
|
||||
titleKey = 'transcribing.subtitles'>
|
||||
<p className = { styles.paragraphWrapper } >
|
||||
{
|
||||
translateToHTML(t, 'transcribing.sourceLanguageDesc', {
|
||||
'sourceLanguage': t(`languages:${i18next.language}`).toLowerCase()
|
||||
})
|
||||
}<span
|
||||
className = { styles.spanWrapper }
|
||||
onClick = { onSourceLanguageClick }>{t('transcribing.sourceLanguageHere')}.</span>
|
||||
</p>
|
||||
<LanguageList
|
||||
items = { listItems }
|
||||
onLanguageSelected = { onSelected }
|
||||
selectedLanguage = { subtitles } />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractLanguageSelector to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default translate(AbstractLanguageSelectorDialog(LanguageSelectorDialog));
|
||||
4
react/features/subtitles/constants.ts
Normal file
4
react/features/subtitles/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The minimum font size for subtitles.
|
||||
*/
|
||||
export const MIN_SUBTITLES_FONT_SIZE = 16;
|
||||
71
react/features/subtitles/functions.any.ts
Normal file
71
react/features/subtitles/functions.any.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { TRANSLATION_LANGUAGES, TRANSLATION_LANGUAGES_HEAD } from '../base/i18n/i18next';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { canAddTranscriber, isTranscribing } from '../transcribing/functions';
|
||||
|
||||
/**
|
||||
* Checks whether the participant can start the subtitles.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - True if the participant can start the subtitles.
|
||||
*/
|
||||
export function canStartSubtitles(state: IReduxState) {
|
||||
return canAddTranscriber(state) || isTranscribing(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of available subtitles languages. The list consists of head languages (fixed items that stay on
|
||||
* top) followed by the rest of available translation languages.
|
||||
*
|
||||
* @param {IStateful} stateful - The stateful object containing the redux state.
|
||||
* @param {string} [selectedLanguage] - Optional language code of currently selected language. If provided and not in
|
||||
* regular translation languages, it will be added after head languages.
|
||||
* @returns {Array<string>} - Array of language codes. Includes both head languages and regular translation languages.
|
||||
*/
|
||||
export function getAvailableSubtitlesLanguages(stateful: IStateful, selectedLanguage?: string | null) {
|
||||
const state = toState(stateful);
|
||||
const { transcription } = state['features/base/config'];
|
||||
|
||||
const translationLanguagesHead = transcription?.translationLanguagesHead ?? TRANSLATION_LANGUAGES_HEAD;
|
||||
const translationLanguages
|
||||
= (transcription?.translationLanguages ?? TRANSLATION_LANGUAGES)
|
||||
.filter((lang: string) => !translationLanguagesHead?.includes(lang) && lang !== selectedLanguage);
|
||||
const isSelectedLanguageNotIncluded = Boolean(
|
||||
selectedLanguage
|
||||
&& !translationLanguages.includes(selectedLanguage)
|
||||
&& !translationLanguagesHead.includes(selectedLanguage));
|
||||
|
||||
return [
|
||||
...translationLanguagesHead,
|
||||
|
||||
// selectedLanguage is redundant but otherwise TS complains about null elements in the array.
|
||||
...isSelectedLanguageNotIncluded && selectedLanguage ? [ selectedLanguage ] : [],
|
||||
...translationLanguages
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines if closed captions are enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state object.
|
||||
* @returns {boolean} A boolean indicating whether closed captions are enabled.
|
||||
*/
|
||||
export function areClosedCaptionsEnabled(state: IReduxState) {
|
||||
const { transcription } = state['features/base/config'];
|
||||
|
||||
return !transcription?.disableClosedCaptions && Boolean(transcription?.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the subtitles tab should be enabled in the UI.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - True if the subtitles tab should be enabled.
|
||||
*/
|
||||
export function isCCTabEnabled(state: IReduxState) {
|
||||
const { showSubtitlesOnStage = false } = state['features/base/settings'];
|
||||
|
||||
return areClosedCaptionsEnabled(state) && !showSubtitlesOnStage;
|
||||
}
|
||||
30
react/features/subtitles/functions.native.ts
Normal file
30
react/features/subtitles/functions.native.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable max-params, max-len */
|
||||
|
||||
import { sendEvent } from '../mobile/external-api/functions';
|
||||
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side to indicate that the transcription chunk was received.
|
||||
*/
|
||||
const TRANSCRIPTION_CHUNK_RECEIVED = 'TRANSCRIPTION_CHUNK_RECEIVED';
|
||||
|
||||
/**
|
||||
* Logs when about the received transcription chunk.
|
||||
*
|
||||
* @param {string} transcriptMessageID - Transcription message id.
|
||||
* @param {string} language - The language of the transcribed message.
|
||||
* @param {Object} participant - The participant who send the message.
|
||||
* @param {any} text - The message text.
|
||||
* @param {any} _store - The store.
|
||||
* @returns {Event}
|
||||
*/
|
||||
export const notifyTranscriptionChunkReceived = (transcriptMessageID: string, language: string, participant: Object, text: any, _store?: any) =>
|
||||
sendEvent(
|
||||
_store,
|
||||
TRANSCRIPTION_CHUNK_RECEIVED,
|
||||
{
|
||||
messageID: transcriptMessageID,
|
||||
language,
|
||||
participant,
|
||||
text
|
||||
});
|
||||
35
react/features/subtitles/functions.web.ts
Normal file
35
react/features/subtitles/functions.web.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/* eslint-disable max-params, max-len */
|
||||
|
||||
import { MIN_SUBTITLES_FONT_SIZE } from './constants';
|
||||
|
||||
/**
|
||||
* Logs when about the received transcription chunk.
|
||||
*
|
||||
* @param {string} transcriptMessageID - Transcription message id.
|
||||
* @param {string} language - The language of the transcribed message.
|
||||
* @param {Object} participant - The participant who send the message.
|
||||
* @param {any} text - The message text.
|
||||
* @param {any} _store - The store.
|
||||
* @returns {Event}
|
||||
*/
|
||||
export const notifyTranscriptionChunkReceived = (transcriptMessageID: string, language: string, participant: Object, text: any, _store?: any) =>
|
||||
APP.API.notifyTranscriptionChunkReceived({
|
||||
messageID: transcriptMessageID,
|
||||
language,
|
||||
participant,
|
||||
...text
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates the font size for the subtitles.
|
||||
*
|
||||
* @param {number} clientHeight - The height of the visible area of the window.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function calculateSubtitlesFontSize(clientHeight?: number) {
|
||||
if (typeof clientHeight === 'undefined') {
|
||||
return MIN_SUBTITLES_FONT_SIZE;
|
||||
}
|
||||
|
||||
return Math.max(Math.floor(clientHeight * 0.04), MIN_SUBTITLES_FONT_SIZE);
|
||||
}
|
||||
31
react/features/subtitles/hooks.web.ts
Normal file
31
react/features/subtitles/hooks.web.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../app/types';
|
||||
|
||||
import ClosedCaptionButton from './components/web/ClosedCaptionButton';
|
||||
import { areClosedCaptionsEnabled, canStartSubtitles } from './functions.any';
|
||||
|
||||
const cc = {
|
||||
key: 'closedcaptions',
|
||||
Content: ClosedCaptionButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the CC button if it is enabled and undefined otherwise.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useClosedCaptionButton() {
|
||||
const isStartSubtitlesButtonVisible = useSelector(canStartSubtitles);
|
||||
const { showSubtitlesOnStage = false } = useSelector((state: IReduxState) => state['features/base/settings']);
|
||||
const _areClosedCaptionsEnabled = useSelector(areClosedCaptionsEnabled);
|
||||
|
||||
if (!_areClosedCaptionsEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isStartSubtitlesButtonVisible || !showSubtitlesOnStage) {
|
||||
return cc;
|
||||
}
|
||||
}
|
||||
3
react/features/subtitles/logger.ts
Normal file
3
react/features/subtitles/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/subtitles');
|
||||
401
react/features/subtitles/middleware.ts
Normal file
401
react/features/subtitles/middleware.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { ENDPOINT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import JitsiMeetJS from '../base/lib-jitsi-meet';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { showErrorNotification } from '../notifications/actions';
|
||||
import { TRANSCRIBER_JOINED } from '../transcribing/actionTypes';
|
||||
|
||||
import {
|
||||
SET_REQUESTING_SUBTITLES,
|
||||
TOGGLE_REQUESTING_SUBTITLES
|
||||
} from './actionTypes';
|
||||
import {
|
||||
removeCachedTranscriptMessage,
|
||||
removeTranscriptMessage,
|
||||
setRequestingSubtitles,
|
||||
setSubtitlesError,
|
||||
storeSubtitle,
|
||||
updateTranscriptMessage
|
||||
} from './actions.any';
|
||||
import { notifyTranscriptionChunkReceived } from './functions';
|
||||
import { areClosedCaptionsEnabled, isCCTabEnabled } from './functions.any';
|
||||
import logger from './logger';
|
||||
import { ISubtitle, ITranscriptMessage } from './types';
|
||||
|
||||
/**
|
||||
* The type of json-message which indicates that json carries a
|
||||
* transcription result.
|
||||
*/
|
||||
const JSON_TYPE_TRANSCRIPTION_RESULT = 'transcription-result';
|
||||
|
||||
/**
|
||||
* The type of json-message which indicates that json carries a
|
||||
* translation result.
|
||||
*/
|
||||
const JSON_TYPE_TRANSLATION_RESULT = 'translation-result';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* The dial command to use for starting a transcriber.
|
||||
*/
|
||||
const TRANSCRIBER_DIAL_NUMBER = 'jitsi_meet_transcribe';
|
||||
|
||||
/**
|
||||
* Time after which the rendered subtitles will be removed.
|
||||
*/
|
||||
const REMOVE_AFTER_MS = 3000;
|
||||
|
||||
/**
|
||||
* Stability factor for a transcription. We'll treat a transcript as stable
|
||||
* beyond this value.
|
||||
*/
|
||||
const STABLE_TRANSCRIPTION_FACTOR = 0.85;
|
||||
|
||||
/**
|
||||
* Middleware that catches actions related to transcript messages to be rendered
|
||||
* in {@link Captions}.
|
||||
*
|
||||
* @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: {
|
||||
const state = store.getState()['features/subtitles'];
|
||||
const toggledValue = !state._requestingSubtitles;
|
||||
|
||||
_requestingSubtitlesChange(store, toggledValue, state._language);
|
||||
break;
|
||||
}
|
||||
case TRANSCRIBER_JOINED: {
|
||||
const { transcription } = store.getState()['features/base/config'];
|
||||
|
||||
if (transcription?.autoCaptionOnTranscribe) {
|
||||
store.dispatch(setRequestingSubtitles(true));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case SET_REQUESTING_SUBTITLES:
|
||||
_requestingSubtitlesChange(store, action.enabled, action.language, action.backendRecordingOn);
|
||||
break;
|
||||
}
|
||||
|
||||
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(store: IStore, next: Function, action: AnyAction) {
|
||||
const { data: json } = action;
|
||||
|
||||
if (![ JSON_TYPE_TRANSCRIPTION_RESULT, JSON_TYPE_TRANSLATION_RESULT ].includes(json?.type)) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const _areClosedCaptionsEnabled = areClosedCaptionsEnabled(store.getState());
|
||||
const transcriptMessageID = json.message_id;
|
||||
const { name, id, avatar_url: avatarUrl } = json.participant;
|
||||
const participant = {
|
||||
avatarUrl,
|
||||
id,
|
||||
name
|
||||
};
|
||||
const { timestamp } = json;
|
||||
const participantId = participant.id;
|
||||
|
||||
// Handle transcript messages
|
||||
const language = state['features/base/conference'].conference
|
||||
?.getLocalParticipantProperty(P_NAME_TRANSLATION_LANGUAGE);
|
||||
const { dumpTranscript, skipInterimTranscriptions } = state['features/base/config'].testing ?? {};
|
||||
|
||||
let newTranscriptMessage: ITranscriptMessage | undefined;
|
||||
|
||||
if (json.type === JSON_TYPE_TRANSLATION_RESULT) {
|
||||
if (!_areClosedCaptionsEnabled) {
|
||||
// If closed captions are not enabled, bail out.
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const translation = json.text?.trim();
|
||||
|
||||
if (isCCTabEnabled(state)) {
|
||||
dispatch(storeSubtitle({
|
||||
participantId,
|
||||
text: translation,
|
||||
language: json.language,
|
||||
interim: false,
|
||||
isTranscription: false,
|
||||
timestamp,
|
||||
id: transcriptMessageID
|
||||
}));
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (json.language === language) {
|
||||
// Displays final results in the target language if translation is
|
||||
// enabled.
|
||||
newTranscriptMessage = {
|
||||
clearTimeOut: undefined,
|
||||
final: json.text?.trim(),
|
||||
participant
|
||||
};
|
||||
}
|
||||
} else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT) {
|
||||
const isInterim = json.is_interim;
|
||||
|
||||
// Displays interim and final results without any translation if
|
||||
// translations are disabled.
|
||||
|
||||
const { text } = json.transcript[0];
|
||||
|
||||
// First, notify the external API.
|
||||
if (!(isInterim && skipInterimTranscriptions)) {
|
||||
const txt: any = {};
|
||||
|
||||
if (!json.is_interim) {
|
||||
txt.final = text;
|
||||
} else if (json.stability > STABLE_TRANSCRIPTION_FACTOR) {
|
||||
txt.stable = text;
|
||||
} else {
|
||||
txt.unstable = text;
|
||||
}
|
||||
|
||||
notifyTranscriptionChunkReceived(
|
||||
transcriptMessageID,
|
||||
json.language,
|
||||
participant,
|
||||
txt,
|
||||
store
|
||||
);
|
||||
|
||||
if (navigator.product !== 'ReactNative') {
|
||||
|
||||
// Dump transcript in a <transcript> element for debugging purposes.
|
||||
if (!json.is_interim && dumpTranscript) {
|
||||
try {
|
||||
let elem = document.body.getElementsByTagName('transcript')[0];
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (!elem) {
|
||||
elem = document.createElement('transcript');
|
||||
document.body.appendChild(elem);
|
||||
}
|
||||
|
||||
elem.append(`${new Date(json.timestamp).toISOString()} ${participant.name}: ${text}`);
|
||||
} catch (_) {
|
||||
// Ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_areClosedCaptionsEnabled) {
|
||||
// If closed captions are not enabled, bail out.
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const subtitle: ISubtitle = {
|
||||
id: transcriptMessageID,
|
||||
participantId,
|
||||
language: json.language,
|
||||
text,
|
||||
interim: isInterim,
|
||||
timestamp,
|
||||
isTranscription: true
|
||||
};
|
||||
|
||||
if (isCCTabEnabled(state)) {
|
||||
dispatch(storeSubtitle(subtitle));
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
// If the user is not requesting transcriptions just bail.
|
||||
// Regex to filter out all possible country codes after language code:
|
||||
// this should catch all notations like 'en-GB' 'en_GB' and 'enGB'
|
||||
// and be independent of the country code length
|
||||
if (!language || (_getPrimaryLanguageCode(json.language) !== _getPrimaryLanguageCode(language))) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (json.is_interim && skipInterimTranscriptions) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
// 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 existingMessage = state['features/subtitles']._transcriptMessages.get(transcriptMessageID);
|
||||
|
||||
newTranscriptMessage = {
|
||||
clearTimeOut: existingMessage?.clearTimeOut,
|
||||
participant
|
||||
};
|
||||
|
||||
// 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 (!json.is_interim) {
|
||||
newTranscriptMessage.final = text;
|
||||
} else if (json.stability > STABLE_TRANSCRIPTION_FACTOR) {
|
||||
// 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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (newTranscriptMessage) {
|
||||
if (newTranscriptMessage.final) {
|
||||
const cachedTranscriptMessage
|
||||
= state['features/subtitles']._cachedTranscriptMessages?.get(transcriptMessageID);
|
||||
|
||||
if (cachedTranscriptMessage) {
|
||||
const cachedText = (cachedTranscriptMessage.stable || cachedTranscriptMessage.unstable)?.trim();
|
||||
const newText = newTranscriptMessage.final;
|
||||
|
||||
if (cachedText && cachedText.length > 0 && newText && newText.length > 0
|
||||
&& newText.toLowerCase().startsWith(cachedText.toLowerCase())) {
|
||||
newTranscriptMessage.final = newText.slice(cachedText.length)?.trim();
|
||||
}
|
||||
dispatch(removeCachedTranscriptMessage(transcriptMessageID));
|
||||
|
||||
if (!newTranscriptMessage.final || newTranscriptMessage.final.length === 0) {
|
||||
return next(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage);
|
||||
dispatch(updateTranscriptMessage(transcriptMessageID, newTranscriptMessage));
|
||||
}
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to extract the primary language code like 'en-GB' 'en_GB'
|
||||
* 'enGB' 'zh-CN' and 'zh-TW'.
|
||||
*
|
||||
* @param {string} language - The language to use for translation or user requested.
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getPrimaryLanguageCode(language: string) {
|
||||
return language.replace(/[-_A-Z].*/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {boolean} enabled - Whether subtitles should be enabled or not.
|
||||
* @param {string} language - The language to use for translation.
|
||||
* @param {boolean} backendRecordingOn - Whether backend recording is on or not.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _requestingSubtitlesChange(
|
||||
{ dispatch, getState }: IStore,
|
||||
enabled: boolean,
|
||||
language?: string | null,
|
||||
backendRecordingOn = false) {
|
||||
const state = getState();
|
||||
const { conference } = state['features/base/conference'];
|
||||
const { transcription } = state['features/base/config'];
|
||||
|
||||
conference?.setLocalParticipantProperty(
|
||||
P_NAME_REQUESTING_TRANSCRIPTION,
|
||||
enabled);
|
||||
|
||||
if (enabled && conference?.getTranscriptionStatus() === JitsiMeetJS.constants.transcriptionStatus.OFF) {
|
||||
const featureAllowed = isJwtFeatureEnabled(getState(), MEET_FEATURES.TRANSCRIPTION, false);
|
||||
|
||||
// the default value for inviteJigasiOnBackendTranscribing is true (when undefined)
|
||||
const inviteJigasi = conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription
|
||||
? (transcription?.inviteJigasiOnBackendTranscribing ?? true) : true;
|
||||
|
||||
if (featureAllowed && (!backendRecordingOn || inviteJigasi)) {
|
||||
conference?.dial(TRANSCRIBER_DIAL_NUMBER)
|
||||
.catch((e: any) => {
|
||||
logger.error('Error dialing', e);
|
||||
|
||||
// let's back to the correct state
|
||||
dispatch(setRequestingSubtitles(false, false, null));
|
||||
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'transcribing.failed'
|
||||
}));
|
||||
dispatch(setSubtitlesError(true));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled && language) {
|
||||
conference?.setLocalParticipantProperty(
|
||||
P_NAME_TRANSLATION_LANGUAGE,
|
||||
language.replace('translation-languages:', ''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a timeout on a TranscriptMessage object so it clears itself when it's not
|
||||
* updated.
|
||||
*
|
||||
* @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(
|
||||
dispatch: IStore['dispatch'],
|
||||
transcriptMessageID: string,
|
||||
transcriptMessage: { clearTimeOut?: number; }) {
|
||||
if (transcriptMessage.clearTimeOut) {
|
||||
clearTimeout(transcriptMessage.clearTimeOut);
|
||||
}
|
||||
|
||||
transcriptMessage.clearTimeOut
|
||||
= window.setTimeout(
|
||||
() => dispatch(removeTranscriptMessage(transcriptMessageID)),
|
||||
REMOVE_AFTER_MS);
|
||||
}
|
||||
180
react/features/subtitles/reducer.ts
Normal file
180
react/features/subtitles/reducer.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { TRANSCRIBER_LEFT } from '../transcribing/actionTypes';
|
||||
|
||||
import {
|
||||
REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
REMOVE_TRANSCRIPT_MESSAGE,
|
||||
SET_REQUESTING_SUBTITLES,
|
||||
SET_SUBTITLES_ERROR,
|
||||
STORE_SUBTITLE,
|
||||
TOGGLE_REQUESTING_SUBTITLES,
|
||||
UPDATE_TRANSCRIPT_MESSAGE
|
||||
} from './actionTypes';
|
||||
import { ISubtitle, ITranscriptMessage } from './types';
|
||||
|
||||
/**
|
||||
* Default State for 'features/transcription' feature.
|
||||
*/
|
||||
const defaultState = {
|
||||
_cachedTranscriptMessages: new Map(),
|
||||
_displaySubtitles: false,
|
||||
_transcriptMessages: new Map(),
|
||||
_requestingSubtitles: false,
|
||||
_language: null,
|
||||
messages: [],
|
||||
subtitlesHistory: [],
|
||||
_hasError: false
|
||||
};
|
||||
|
||||
export interface ISubtitlesState {
|
||||
_cachedTranscriptMessages: Map<string, ITranscriptMessage>;
|
||||
_displaySubtitles: boolean;
|
||||
_hasError: boolean;
|
||||
_language: string | null;
|
||||
_requestingSubtitles: boolean;
|
||||
_transcriptMessages: Map<string, ITranscriptMessage>;
|
||||
messages: ITranscriptMessage[];
|
||||
subtitlesHistory: Array<ISubtitle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for actions for the transcription feature to be used by the actions
|
||||
* to update the rendered transcription subtitles.
|
||||
*/
|
||||
ReducerRegistry.register<ISubtitlesState>('features/subtitles', (
|
||||
state = defaultState, action): ISubtitlesState => {
|
||||
switch (action.type) {
|
||||
case REMOVE_TRANSCRIPT_MESSAGE:
|
||||
return _removeTranscriptMessage(state, action);
|
||||
case REMOVE_CACHED_TRANSCRIPT_MESSAGE:
|
||||
return _removeCachedTranscriptMessage(state, action);
|
||||
case UPDATE_TRANSCRIPT_MESSAGE:
|
||||
return _updateTranscriptMessage(state, action);
|
||||
case SET_REQUESTING_SUBTITLES:
|
||||
return {
|
||||
...state,
|
||||
_displaySubtitles: action.displaySubtitles,
|
||||
_language: action.language,
|
||||
_requestingSubtitles: action.enabled,
|
||||
_hasError: false
|
||||
};
|
||||
case TOGGLE_REQUESTING_SUBTITLES:
|
||||
return {
|
||||
...state,
|
||||
_requestingSubtitles: !state._requestingSubtitles,
|
||||
_hasError: false
|
||||
};
|
||||
case TRANSCRIBER_LEFT:
|
||||
return {
|
||||
...state,
|
||||
...defaultState
|
||||
};
|
||||
case STORE_SUBTITLE: {
|
||||
const existingIndex = state.subtitlesHistory.findIndex(
|
||||
subtitle => subtitle.id === action.subtitle.id
|
||||
);
|
||||
|
||||
if (existingIndex >= 0 && state.subtitlesHistory[existingIndex].interim) {
|
||||
const newHistory = [ ...state.subtitlesHistory ];
|
||||
|
||||
newHistory[existingIndex] = action.subtitle;
|
||||
|
||||
return {
|
||||
...state,
|
||||
subtitlesHistory: newHistory
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
subtitlesHistory: [
|
||||
...state.subtitlesHistory,
|
||||
action.subtitle
|
||||
]
|
||||
};
|
||||
}
|
||||
case SET_SUBTITLES_ERROR:
|
||||
return {
|
||||
...state,
|
||||
_hasError: action.hasError
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
|
||||
/**
|
||||
* 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: ISubtitlesState, { transcriptMessageID }: { transcriptMessageID: string; }) {
|
||||
const newTranscriptMessages = new Map(state._transcriptMessages);
|
||||
const message = newTranscriptMessages.get(transcriptMessageID);
|
||||
let { _cachedTranscriptMessages } = state;
|
||||
|
||||
if (message && !message.final) {
|
||||
_cachedTranscriptMessages = new Map(_cachedTranscriptMessages);
|
||||
_cachedTranscriptMessages.set(transcriptMessageID, message);
|
||||
}
|
||||
|
||||
// Deletes the key from Map once a final message arrives.
|
||||
newTranscriptMessages.delete(transcriptMessageID);
|
||||
|
||||
return {
|
||||
...state,
|
||||
_cachedTranscriptMessages,
|
||||
_transcriptMessages: newTranscriptMessages
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reduces a specific Redux action REMOVE_CACHED_TRANSCRIPT_MESSAGE of the feature transcription.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature transcription.
|
||||
* @param {Action} action -The Redux action REMOVE_CACHED_TRANSCRIPT_MESSAGE to reduce.
|
||||
* @returns {Object} The new state of the feature transcription after the reduction of the specified action.
|
||||
*/
|
||||
function _removeCachedTranscriptMessage(state: ISubtitlesState,
|
||||
{ transcriptMessageID }: { transcriptMessageID: string; }) {
|
||||
const newCachedTranscriptMessages = new Map(state._cachedTranscriptMessages);
|
||||
|
||||
// Deletes the key from Map once a final message arrives.
|
||||
newCachedTranscriptMessages.delete(transcriptMessageID);
|
||||
|
||||
return {
|
||||
...state,
|
||||
_cachedTranscriptMessages: newCachedTranscriptMessages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: ISubtitlesState, { transcriptMessageID, newTranscriptMessage }:
|
||||
{ newTranscriptMessage: ITranscriptMessage; transcriptMessageID: string; }) {
|
||||
const newTranscriptMessages = new Map(state._transcriptMessages);
|
||||
const _cachedTranscriptMessages = new Map(state._cachedTranscriptMessages);
|
||||
|
||||
_cachedTranscriptMessages.delete(transcriptMessageID);
|
||||
|
||||
// Updates the new message for the given key in the Map.
|
||||
newTranscriptMessages.set(transcriptMessageID, newTranscriptMessage);
|
||||
|
||||
return {
|
||||
...state,
|
||||
_cachedTranscriptMessages,
|
||||
_transcriptMessages: newTranscriptMessages
|
||||
};
|
||||
}
|
||||
23
react/features/subtitles/types.ts
Normal file
23
react/features/subtitles/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IGroupableMessage } from '../base/util/messageGrouping';
|
||||
|
||||
export interface ITranscriptMessage {
|
||||
clearTimeOut?: number;
|
||||
final?: string;
|
||||
participant: {
|
||||
avatarUrl?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
stable?: string;
|
||||
unstable?: string;
|
||||
}
|
||||
|
||||
export interface ISubtitle extends IGroupableMessage {
|
||||
id: string;
|
||||
interim?: boolean;
|
||||
isTranscription?: boolean;
|
||||
language?: string;
|
||||
participantId: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
Reference in New Issue
Block a user