init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View 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
};
}

View File

@@ -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
};
}

View File

@@ -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;

View 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);

View File

@@ -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));

View 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;

View File

@@ -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);

View File

@@ -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);

View 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]
}
};

View 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));

View File

@@ -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));

View 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;

View 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);

View 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;

View File

@@ -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));