snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
36
react/features/audio-translation/actionTypes.ts
Normal file
36
react/features/audio-translation/actionTypes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The type of (redux) action which clears all AI audio translation state (the default
|
||||
* language and every per-participant override), e.g. after the bridge rejects a request.
|
||||
*/
|
||||
export const CLEAR_AUDIO_TRANSLATION = 'CLEAR_AUDIO_TRANSLATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which clears the set of translated sources the bridge is currently
|
||||
* forwarding to us, e.g. when the conference is left.
|
||||
*/
|
||||
export const CLEAR_RECEIVING_TRANSLATED_SOURCES = 'CLEAR_RECEIVING_TRANSLATED_SOURCES';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the default AI audio translation target
|
||||
* language for every speaker, or null when translation is turned off.
|
||||
*/
|
||||
export const SET_AUDIO_TRANSLATION_LANGUAGE = 'SET_AUDIO_TRANSLATION_LANGUAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the AI audio translation target language
|
||||
* for a single participant (overriding the default), or null to disable it for
|
||||
* that participant.
|
||||
*/
|
||||
export const SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE = 'SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the list of remote participants currently translating the local
|
||||
* participant's audio (i.e. who to show the "translation enabled" badge on).
|
||||
*/
|
||||
export const SET_TRANSLATION_LISTENERS = 'SET_TRANSLATION_LISTENERS';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which records that the bridge started or stopped forwarding a translated
|
||||
* source to the local endpoint (i.e. whether we are hearing that source translated).
|
||||
*/
|
||||
export const UPDATE_TRANSLATED_SOURCE_SENDING = 'UPDATE_TRANSLATED_SOURCE_SENDING';
|
||||
118
react/features/audio-translation/actions.ts
Normal file
118
react/features/audio-translation/actions.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
CLEAR_AUDIO_TRANSLATION,
|
||||
CLEAR_RECEIVING_TRANSLATED_SOURCES,
|
||||
SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_TRANSLATION_LISTENERS,
|
||||
UPDATE_TRANSLATED_SOURCE_SENDING
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Clears all AI audio translation state — the default language and every per-participant
|
||||
* override. Used to reset the feature, e.g. when the bridge rejects a translation request.
|
||||
*
|
||||
* @returns {{
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function clearAudioTranslation() {
|
||||
return {
|
||||
type: CLEAR_AUDIO_TRANSLATION
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the set of translated sources the bridge is currently forwarding to us. Used when the
|
||||
* conference is left so stale received-translation state does not leak into the next conference.
|
||||
*
|
||||
* @returns {{
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function clearReceivingTranslatedSources() {
|
||||
return {
|
||||
type: CLEAR_RECEIVING_TRANSLATED_SOURCES
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the default target language for AI audio translation. The bridge is asked
|
||||
* to translate every remote speaker into this language and the local endpoint
|
||||
* subscribes to the resulting translated sources (in addition to the originals,
|
||||
* which are ducked during playback). Pass null to turn translation off.
|
||||
*
|
||||
* @param {string|null} language - The 2-letter ISO target language code, or null
|
||||
* to disable translation.
|
||||
* @returns {{
|
||||
* language: (string | null),
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function setAudioTranslationLanguage(language: string | null) {
|
||||
return {
|
||||
type: SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the AI audio translation target language for a single participant,
|
||||
* overriding the default. Pass null to disable translation for that participant.
|
||||
*
|
||||
* @param {string} participantId - The participant whose translation language to set.
|
||||
* @param {string|null} language - The 2-letter ISO target language code, or null
|
||||
* to disable translation for this participant.
|
||||
* @returns {{
|
||||
* language: (string | null),
|
||||
* participantId: string,
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function setParticipantAudioTranslationLanguage(participantId: string, language: string | null) {
|
||||
return {
|
||||
type: SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE,
|
||||
participantId,
|
||||
language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint ids of the remote participants currently translating the local participant's audio,
|
||||
* as pushed by the audio-translation component. Drives the per-participant "translation enabled" badge.
|
||||
*
|
||||
* @param {Array<string>} ids - The endpoint ids translating the local participant.
|
||||
* @returns {{
|
||||
* ids: Array<string>,
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function setTranslationListeners(ids: string[]) {
|
||||
return {
|
||||
type: SET_TRANSLATION_LISTENERS,
|
||||
ids
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether the bridge is currently forwarding a translated source to the local endpoint. A translated
|
||||
* source follows the {@code <endpointId>-a<idx>.<lang>} convention. The timestamp is the RTP timestamp from the
|
||||
* event (48 kHz, wraps) and is kept for completeness only — the boolean state is last-write-wins.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @param {boolean} sending - Whether the bridge started (true) or stopped (false) forwarding the source.
|
||||
* @param {number} timestamp - The RTP timestamp carried by the event.
|
||||
* @returns {{
|
||||
* sending: boolean,
|
||||
* sourceName: string,
|
||||
* timestamp: number,
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function updateTranslatedSourceSending(sourceName: string, sending: boolean, timestamp: number) {
|
||||
return {
|
||||
type: UPDATE_TRANSLATED_SOURCE_SENDING,
|
||||
sending,
|
||||
sourceName,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import { isAudioTranslationAvailable } from '../../functions';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* The target language remote audio is currently translated into, or null.
|
||||
*/
|
||||
_language: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An overflow-menu button that opens the AI audio-translation language selector screen.
|
||||
*/
|
||||
class AudioTranslationButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.audioTranslation';
|
||||
override icon = IconTranslate;
|
||||
override label = 'toolbar.audioTranslation';
|
||||
|
||||
/**
|
||||
* Handles clicking the button by opening the language selector screen.
|
||||
*
|
||||
* @override
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
navigate(screen.conference.audioTranslation, { participantId: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in the toggled (translation-on) state.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return Boolean(this.props._language);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the button's props.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_language: state['features/audio-translation'].language,
|
||||
visible: isAudioTranslationAvailable(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AudioTranslationButton));
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleProp, ViewStyle } from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { goBack } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { setAudioTranslationLanguage, setParticipantAudioTranslationLanguage } from '../../actions';
|
||||
import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../constants';
|
||||
|
||||
import AudioTranslationLanguageItem from './AudioTranslationLanguageItem';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Navigation route; params.participantId scopes the selector to one participant.
|
||||
*/
|
||||
route?: { params?: { participantId?: string; }; };
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen for selecting the AI audio-translation target language (or off).
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationDialog = ({ route, t }: IProps) => {
|
||||
const participantId = route?.params?.participantId;
|
||||
const dispatch = useDispatch();
|
||||
const defaultLanguage = useSelector((state: IReduxState) => state['features/audio-translation'].language);
|
||||
const participantLanguages
|
||||
= useSelector((state: IReduxState) => state['features/audio-translation'].participantLanguages);
|
||||
|
||||
// Highlight the effective language for the target: the per-participant override if one is set, else the
|
||||
// default. (For the conference-wide screen there is no participantId, so this is just the default.)
|
||||
const language = participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: defaultLanguage;
|
||||
|
||||
const onSelect = useCallback((code: string | null) => {
|
||||
if (participantId) {
|
||||
dispatch(setParticipantAudioTranslationLanguage(participantId, code));
|
||||
} else {
|
||||
dispatch(setAudioTranslationLanguage(code));
|
||||
}
|
||||
goBack();
|
||||
}, [ dispatch, participantId ]);
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { styles.container }>
|
||||
<ScrollView
|
||||
bounces = { false }
|
||||
style = { styles.itemsContainer as StyleProp<ViewStyle> }>
|
||||
<AudioTranslationLanguageItem
|
||||
code = { null }
|
||||
label = { t('toolbar.audioTranslationOff') }
|
||||
onSelect = { onSelect }
|
||||
selected = { !language } />
|
||||
{ SUPPORTED_TRANSLATION_LANGUAGES.map(({ code, label }) => (
|
||||
<AudioTranslationLanguageItem
|
||||
code = { code }
|
||||
key = { code }
|
||||
label = { label }
|
||||
onSelect = { onSelect }
|
||||
selected = { language === code } />
|
||||
)) }
|
||||
</ScrollView>
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(AudioTranslationDialog);
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { StyleProp, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCheck } from '../../../base/icons/svg';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The language code this item selects, or null for the "off" item.
|
||||
*/
|
||||
code: string | null;
|
||||
|
||||
/**
|
||||
* The label shown for the item.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Invoked with the item's code when the item is pressed.
|
||||
*/
|
||||
onSelect: (code: string | null) => void;
|
||||
|
||||
/**
|
||||
* Whether this item is the current selection.
|
||||
*/
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in the native audio-translation language selector.
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationLanguageItem = ({ code, label, onSelect, selected }: IProps) => {
|
||||
const onPress = useCallback(() => onSelect(code), [ code, onSelect ]);
|
||||
|
||||
return (
|
||||
<View style = { styles.languageItemWrapper as StyleProp<ViewStyle> }>
|
||||
<View style = { styles.iconWrapper as StyleProp<ViewStyle> }>
|
||||
{ selected && <Icon
|
||||
size = { 20 }
|
||||
src = { IconCheck } /> }
|
||||
</View>
|
||||
<TouchableHighlight
|
||||
onPress = { onPress }
|
||||
underlayColor = { 'transparent' }>
|
||||
<Text style = { [ styles.languageItemText, selected && styles.selectedLanguageItemText ] }>
|
||||
{ label }
|
||||
</Text>
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioTranslationLanguageItem;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
|
||||
|
||||
type Props = AbstractProps & WithTranslation;
|
||||
|
||||
/**
|
||||
* A tooltip-like expanded label explaining the meaning of the audio-translation conference label.
|
||||
*/
|
||||
class TranslationExpandedLabel extends ExpandedLabel<Props> {
|
||||
|
||||
/**
|
||||
* Returns the label specific text of this {@code ExpandedLabel}.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLabel() {
|
||||
return this.props.t('audioTranslation.labelTooltip');
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(TranslationExpandedLabel);
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/native/Label';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
import { isAudioTranslationActiveInMeeting, isAudioTranslationAvailable } from '../../functions';
|
||||
|
||||
const styles = {
|
||||
translationLabel: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: BaseTheme.palette.action01,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
flexDirection: 'row',
|
||||
marginBottom: BaseTheme.spacing[0],
|
||||
marginLeft: BaseTheme.spacing[0]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Conference-header label shown while audio translation is active in the meeting.
|
||||
*
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
const TranslationLabel = () => {
|
||||
const available = useSelector(isAudioTranslationAvailable);
|
||||
const active = useSelector(isAudioTranslationActiveInMeeting);
|
||||
|
||||
return available && active ? (
|
||||
<Label
|
||||
icon = { IconTranslate }
|
||||
iconColor = { BaseTheme.palette.icon01 }
|
||||
style = { styles.translationLabel } />
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TranslationLabel;
|
||||
37
react/features/audio-translation/components/native/styles.ts
Normal file
37
react/features/audio-translation/components/native/styles.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/**
|
||||
* The styles of the native audio-translation components.
|
||||
*/
|
||||
export default {
|
||||
container: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
iconWrapper: {
|
||||
width: 32
|
||||
},
|
||||
|
||||
itemsContainer: {
|
||||
marginHorizontal: BaseTheme.spacing[4],
|
||||
marginVertical: BaseTheme.spacing[4]
|
||||
},
|
||||
|
||||
languageItemText: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginLeft: BaseTheme.spacing[2],
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
languageItemWrapper: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
selectedLanguageItemText: {
|
||||
...BaseTheme.typography.bodyShortBoldLarge
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
|
||||
import AudioTranslationDialog from './AudioTranslationDialog';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* The target language remote audio is currently translated into, or null.
|
||||
*/
|
||||
_language: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A toolbar button that opens the AI audio-translation language selector.
|
||||
*/
|
||||
class AudioTranslationButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.audioTranslation';
|
||||
override icon = IconTranslate;
|
||||
override label = 'toolbar.audioTranslation';
|
||||
override tooltip = 'toolbar.audioTranslation';
|
||||
|
||||
/**
|
||||
* Handles clicking the button by opening the language selector dialog.
|
||||
*
|
||||
* @override
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
this.props.dispatch(openDialog('AudioTranslationDialog', AudioTranslationDialog));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in the toggled (translation-on) state.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return Boolean(this.props._language);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the button's props.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_language: state['features/audio-translation'].language
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AudioTranslationButton));
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { hideDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { setAudioTranslationLanguage, setParticipantAudioTranslationLanguage } from '../../actions';
|
||||
import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../constants';
|
||||
|
||||
import AudioTranslationLanguageItem from './AudioTranslationLanguageItem';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* When set, the dialog targets this single participant (overriding the default); otherwise it sets the
|
||||
* default language for every speaker.
|
||||
*/
|
||||
participantId?: string;
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
list: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* A dialog for selecting the AI audio-translation target language (or off).
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationDialog = ({ participantId, t }: IProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const { classes: styles } = useStyles();
|
||||
const defaultLanguage = useSelector((state: IReduxState) => state['features/audio-translation'].language);
|
||||
const participantLanguages
|
||||
= useSelector((state: IReduxState) => state['features/audio-translation'].participantLanguages);
|
||||
|
||||
// Highlight the effective language for the target: the per-participant override if one is set, else the
|
||||
// default. (For the conference-wide dialog there is no participantId, so this is just the default.)
|
||||
const language = participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: defaultLanguage;
|
||||
|
||||
const onSelect = useCallback((code: string | null) => {
|
||||
if (participantId) {
|
||||
dispatch(setParticipantAudioTranslationLanguage(participantId, code));
|
||||
} else {
|
||||
dispatch(setAudioTranslationLanguage(code));
|
||||
}
|
||||
dispatch(hideDialog());
|
||||
}, [ dispatch, participantId ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
ok = {{ hidden: true }}
|
||||
titleKey = 'toolbar.audioTranslation'>
|
||||
<div className = { styles.list }>
|
||||
<AudioTranslationLanguageItem
|
||||
code = { null }
|
||||
label = { t('toolbar.audioTranslationOff') }
|
||||
onSelect = { onSelect }
|
||||
selected = { !language } />
|
||||
{ SUPPORTED_TRANSLATION_LANGUAGES.map(({ code, label }) => (
|
||||
<AudioTranslationLanguageItem
|
||||
code = { code }
|
||||
key = { code }
|
||||
label = { label }
|
||||
onSelect = { onSelect }
|
||||
selected = { language === code } />
|
||||
)) }
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(AudioTranslationDialog);
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The target language code, or null for the "off" option.
|
||||
*/
|
||||
code: string | null;
|
||||
|
||||
/**
|
||||
* The label to display.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Called with this item's code when it is selected.
|
||||
*/
|
||||
onSelect: (code: string | null) => void;
|
||||
|
||||
/**
|
||||
* Whether this item is the currently-selected option.
|
||||
*/
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
color: theme.palette.text01,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
},
|
||||
selected: {
|
||||
fontWeight: 700,
|
||||
color: theme.palette.link01
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* A single selectable language row in the audio-translation dialog.
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationLanguageItem = ({ code, label, onSelect, selected }: IProps) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const onClick = useCallback(() => onSelect(code), [ code, onSelect ]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className = { cx(styles.item, selected && styles.selected) }
|
||||
onClick = { onClick }
|
||||
type = 'button'>
|
||||
{ label }
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioTranslationLanguageItem;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/web/Label';
|
||||
import Tooltip from '../../../base/tooltip/components/Tooltip';
|
||||
import { isAudioTranslationActiveInMeeting } from '../../functions';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
translation: {
|
||||
background: theme.palette.action01
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Conference-header label shown while audio translation is active in the meeting.
|
||||
*
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
const TranslationLabel = () => {
|
||||
const { classes: styles } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const active = useSelector(isAudioTranslationActiveInMeeting);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = t('audioTranslation.labelTooltip');
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content = { content }
|
||||
position = { 'bottom' }>
|
||||
<Label
|
||||
accessibilityText = { content }
|
||||
className = { styles.translation }
|
||||
icon = { IconTranslate } />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslationLabel;
|
||||
55
react/features/audio-translation/constants.ts
Normal file
55
react/features/audio-translation/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Supported AI audio-translation target languages: ISO code + picker label.
|
||||
*/
|
||||
export const SUPPORTED_TRANSLATION_LANGUAGES: Array<{ code: string; label: string; }> = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'hi', label: 'हिन्दी' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Full volume (0..1) a speaker's original audio is restored to once it is no longer ducked.
|
||||
*/
|
||||
export const DEFAULT_ORIGINAL_VOLUME = 1;
|
||||
|
||||
/**
|
||||
* Volume (0..1) a speaker's original audio is ducked to while its translation plays.
|
||||
*/
|
||||
export const DUCKED_ORIGINAL_VOLUME = 0.15;
|
||||
|
||||
/**
|
||||
* The per-participant audio-translation status the thumbnail indicator renders, combining whether translation
|
||||
* is enabled for the local user and whether translated audio is actually being received.
|
||||
*/
|
||||
export enum TranslationTreatment {
|
||||
|
||||
/**
|
||||
* Translation is enabled for the local user AND translated audio is being received.
|
||||
*/
|
||||
BOTH = 'both',
|
||||
|
||||
/**
|
||||
* Translation is enabled for the local user but no translated audio is being received yet.
|
||||
*/
|
||||
ENABLED = 'enabled',
|
||||
|
||||
/**
|
||||
* Neither enabled nor receiving — the indicator renders nothing.
|
||||
*/
|
||||
NONE = 'none',
|
||||
|
||||
/**
|
||||
* Translated audio is being received without translation having been explicitly enabled.
|
||||
*/
|
||||
RECEIVING = 'receiving'
|
||||
}
|
||||
301
react/features/audio-translation/functions.ts
Normal file
301
react/features/audio-translation/functions.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { AUDIO_TRANSLATION_ENABLED } from '../base/flags/constants';
|
||||
import { getFeatureFlag } from '../base/flags/functions';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { isLocalParticipantModerator } from '../base/participants/functions';
|
||||
import { ITrack } from '../base/tracks/types';
|
||||
|
||||
import { DUCKED_ORIGINAL_VOLUME, TranslationTreatment } from './constants';
|
||||
|
||||
/**
|
||||
* Whether audio translation is enabled for the room. Driven by the {@code audioTranslation} RoomMetadata flag,
|
||||
* which a moderator can toggle; absent or any value other than an explicit {@code false} means enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationRoomEnabled(state: IReduxState): boolean {
|
||||
return state['features/base/conference'].metadata?.audioTranslation?.enabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given participant is currently translating the local participant's audio, per the directed
|
||||
* {@code translationListeners} list pushed by the audio-translation component.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isParticipantAudioTranslationActive(state: IReduxState, participantId: string): boolean {
|
||||
return state['features/audio-translation'].translationListeners.includes(participantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target language encoded in a translated source name — the substring after the last {@code .}.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSourceLanguage(sourceName: string): string {
|
||||
const dotIndex = sourceName.lastIndexOf('.');
|
||||
|
||||
return dotIndex === -1 ? '' : sourceName.substring(dotIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner endpoint id encoded in a translated source name — the substring before the first {@code -}.
|
||||
* Translated sources follow the {@code <endpointId>-a<idx>.<lang>} convention (endpoint ids are dash/dot-free).
|
||||
* Returns an empty string for a source without a dash.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSourceOwnerEndpointId(sourceName: string): string {
|
||||
const dashIndex = sourceName.indexOf('-');
|
||||
|
||||
return dashIndex === -1 ? '' : sourceName.substring(0, dashIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we are hearing the given participant translated. The bridge floods sending changes to every
|
||||
* endpoint, so {@code receivingSources} is every translated source in flight anywhere, not just ours; a
|
||||
* source counts as ours only when its language matches the one we selected for this participant.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isReceivingTranslationFrom(state: IReduxState, participantId: string): boolean {
|
||||
const language = getEffectiveTranslationLanguage(state, participantId);
|
||||
|
||||
// The bridge announces sending changes to every endpoint, so a source being in flight does not mean we
|
||||
// subscribed to it; require our own selection for this speaker to match the source's language.
|
||||
if (!language) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state['features/audio-translation'].receivingSources
|
||||
.some(sourceName => getSourceOwnerEndpointId(sourceName) === participantId
|
||||
&& sourceName.endsWith(`.${language}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio-translation status treatment for a participant, combining whether translation is enabled for the
|
||||
* local user ({@link isParticipantAudioTranslationActive}) and whether translated audio is being received
|
||||
* ({@link isReceivingTranslationFrom}).
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {TranslationTreatment}
|
||||
*/
|
||||
export function getTranslationTreatment(state: IReduxState, participantId: string): TranslationTreatment {
|
||||
const enabled = isParticipantAudioTranslationActive(state, participantId);
|
||||
const receiving = isReceivingTranslationFrom(state, participantId);
|
||||
|
||||
if (enabled && receiving) {
|
||||
return TranslationTreatment.BOTH;
|
||||
}
|
||||
if (enabled) {
|
||||
return TranslationTreatment.ENABLED;
|
||||
}
|
||||
if (receiving) {
|
||||
return TranslationTreatment.RECEIVING;
|
||||
}
|
||||
|
||||
return TranslationTreatment.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether audio translation is currently active anywhere in the meeting: a remote participant is translating
|
||||
* the local user, translated audio is being received, or the local user has translation on.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationActiveInMeeting(state: IReduxState): boolean {
|
||||
const { language, participantLanguages, receivingSources, translationListeners }
|
||||
= state['features/audio-translation'];
|
||||
|
||||
return translationListeners.length > 0
|
||||
|| receivingSources.length > 0
|
||||
|| Boolean(language)
|
||||
|| Object.values(participantLanguages).some(lang => lang !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local user may manage audio translation for the room (i.e. toggle the room-wide flag). Mirrors the
|
||||
* prosody {@code live-translation} permission: the token's explicit value wins, otherwise moderators have it.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canManageAudioTranslation(state: IReduxState): boolean {
|
||||
return isJwtFeatureEnabled(state, MEET_FEATURES.LIVE_TRANSLATION, isLocalParticipantModerator(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the audio-translation UI should be available to the local user: the feature must be deployed
|
||||
* ({@code config.audioTranslation.enabled}) and either the user can manage it or it is enabled for the room.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationAvailable(state: IReduxState): boolean {
|
||||
// SDK embedder kill switch; defaults true, so web (no flags state) is unaffected.
|
||||
if (!getFeatureFlag(state, AUDIO_TRANSLATION_ENABLED, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state['features/base/config'].audioTranslation?.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-room availability via RoomMetadata; only an explicit false hides it.
|
||||
if (state['features/base/conference'].metadata?.audioTranslationAvailable === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canManageAudioTranslation(state) || isAudioTranslationRoomEnabled(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoization for {@link getTranslatedSourceNames}: the tracks array is replaced on every tracks update, so
|
||||
* the set is rebuilt only when that reference changes and is shared by all callers in between.
|
||||
*/
|
||||
let _lastTracks: ITrack[] | undefined;
|
||||
let _lastTranslatedSourceNames = new Set<string>();
|
||||
|
||||
/**
|
||||
* Returns the set of translated audio source names currently present in the conference. Translated sources
|
||||
* follow the {@code <sourceName>.<language>} convention, so any source name containing a dot is one. Memoized
|
||||
* on the tracks array reference, letting callers do an O(1) lookup instead of scanning every track.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Set<string>} The translated source names present in the conference.
|
||||
*/
|
||||
export function getTranslatedSourceNames(state: IReduxState): Set<string> {
|
||||
const tracks = state['features/base/tracks'];
|
||||
|
||||
if (tracks === _lastTracks) {
|
||||
return _lastTranslatedSourceNames;
|
||||
}
|
||||
|
||||
const translated = new Set<string>();
|
||||
|
||||
for (const track of tracks) {
|
||||
const sourceName = track.jitsiTrack?.getSourceName?.();
|
||||
|
||||
if (typeof sourceName === 'string' && sourceName.includes('.')) {
|
||||
translated.add(sourceName);
|
||||
}
|
||||
}
|
||||
|
||||
_lastTracks = tracks;
|
||||
_lastTranslatedSourceNames = translated;
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-participant override language if set, else the conference-wide default.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {string | null} The target language, or null.
|
||||
*/
|
||||
export function getEffectiveTranslationLanguage(state: IReduxState, participantId?: string): string | null {
|
||||
const { language, participantLanguages } = state['features/audio-translation'];
|
||||
|
||||
return participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to duck a speaker's original: a language is set and its translated counterpart is present.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [sourceName] - The original audio source name.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldDuckOriginalAudio(state: IReduxState, sourceName?: string, participantId?: string): boolean {
|
||||
const language = getEffectiveTranslationLanguage(state, participantId);
|
||||
|
||||
if (!language || typeof sourceName !== 'string' || sourceName.endsWith(`.${language}`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getTranslatedSourceNames(state).has(`${sourceName}.${language}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ducked volume (0..1) from config.audioTranslation.duckedVolume; invalid values fall back to default.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDuckedVolume(state: IReduxState): number {
|
||||
const configured = state['features/base/config'].audioTranslation?.duckedVolume;
|
||||
|
||||
return typeof configured === 'number' && configured >= 0 && configured <= 1
|
||||
? configured
|
||||
: DUCKED_ORIGINAL_VOLUME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ducked volume, capped at the user's chosen volume so ducking never makes a quieted speaker louder.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDuckedVolumeForParticipant(state: IReduxState, participantId?: string): number {
|
||||
const duckedVolume = getDuckedVolume(state);
|
||||
const userVolume = participantId ? state['features/filmstrip'].participantsVolume[participantId] : undefined;
|
||||
|
||||
return typeof userVolume === 'number' ? Math.min(userVolume, duckedVolume) : duckedVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of participants still hearing the given speaker's translated audio, as published by that
|
||||
* speaker. 0 when nothing is pending or the speaker's client does not publish the count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The speaker's participant id.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getTranslationDeliveryPendingCount(state: IReduxState, participantId: string): number {
|
||||
const counts = state['features/base/conference'].metadata?.audioTranslationListenerCounts?.[participantId];
|
||||
|
||||
if (!counts) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sum the subscribers of only the languages the bridge is still sending for this speaker: the in-flight
|
||||
// language count alone undercounts (one source serves every listener of that language) and the speaker's
|
||||
// subscriber total overcounts (a language whose stream already finished).
|
||||
return state['features/audio-translation'].receivingSources.reduce((total, sourceName) =>
|
||||
getSourceOwnerEndpointId(sourceName) === participantId
|
||||
? total + (counts[getSourceLanguage(sourceName)] ?? 0)
|
||||
: total, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether translated audio for the given speaker is still being delivered, so others should wait before
|
||||
* speaking. Falls back to any in-flight translated source owned by the speaker, so the ring still shows when
|
||||
* the speaker's client does not publish a count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The speaker's participant id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTranslationDeliveryPending(state: IReduxState, participantId: string): boolean {
|
||||
if (getTranslationDeliveryPendingCount(state, participantId) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state['features/audio-translation'].receivingSources
|
||||
.some(sourceName => getSourceOwnerEndpointId(sourceName) === participantId);
|
||||
}
|
||||
28
react/features/audio-translation/hooks.web.ts
Normal file
28
react/features/audio-translation/hooks.web.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import AudioTranslationButton from './components/web/AudioTranslationButton';
|
||||
import { isAudioTranslationAvailable } from './functions';
|
||||
|
||||
const audioTranslation = {
|
||||
key: 'audiotranslation',
|
||||
Content: AudioTranslationButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the audio-translation toolbar button when the feature is available to the local user,
|
||||
* and undefined otherwise. Audio translation requires the bridge translation backend (opt-in via
|
||||
* config.audioTranslation.enabled), is hidden when a moderator has disabled it for the room, and is always
|
||||
* shown to those who can manage it.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useAudioTranslationButton() {
|
||||
const available = useSelector(isAudioTranslationAvailable);
|
||||
|
||||
if (!available) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return audioTranslation;
|
||||
}
|
||||
3
react/features/audio-translation/logger.ts
Normal file
3
react/features/audio-translation/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('app:audio-translation');
|
||||
168
react/features/audio-translation/middleware.any.ts
Normal file
168
react/features/audio-translation/middleware.any.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { JitsiAudioTranslationErrors, JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { showErrorNotification, showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
|
||||
import {
|
||||
CLEAR_AUDIO_TRANSLATION,
|
||||
SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE
|
||||
} from './actionTypes';
|
||||
import {
|
||||
clearAudioTranslation,
|
||||
clearReceivingTranslatedSources,
|
||||
setTranslationListeners,
|
||||
updateTranslatedSourceSending
|
||||
} from './actions';
|
||||
import { isAudioTranslationRoomEnabled } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Maps an audio-translation component error condition to a notification message key. Unmapped conditions
|
||||
* fall back to {@link DEFAULT_ERROR_KEY}.
|
||||
*/
|
||||
const ERROR_NOTIFICATION_KEYS: { [condition: string]: string; } = {
|
||||
[JitsiAudioTranslationErrors.FORBIDDEN]: 'audioTranslation.errorForbidden',
|
||||
[JitsiAudioTranslationErrors.SPEAKER_UNAVAILABLE]: 'audioTranslation.errorSpeakerUnavailable',
|
||||
[JitsiAudioTranslationErrors.SUBSCRIPTION_LIMIT_REACHED]: 'audioTranslation.errorLimitReached'
|
||||
};
|
||||
|
||||
const DEFAULT_ERROR_KEY = 'audioTranslation.errorGeneric';
|
||||
|
||||
|
||||
/**
|
||||
* Middleware that drives the bridge-side translation when the local user changes
|
||||
* the default or a per-participant audio-translation target language.
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
// Block enabling a translation language while a moderator has disabled the feature for the room. Managers
|
||||
// keep the translation UI even when the room flag is off, and the bridge silently drops such requests, so
|
||||
// surface a notification here instead. Disabling (language === null) is always allowed through.
|
||||
if ((action.type === SET_AUDIO_TRANSLATION_LANGUAGE || action.type === SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE)
|
||||
&& action.language
|
||||
&& !isAudioTranslationRoomEnabled(store.getState())) {
|
||||
store.dispatch(showErrorNotification({
|
||||
titleKey: 'audioTranslation.errorRoomDisabled'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case CLEAR_AUDIO_TRANSLATION: {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (conference) {
|
||||
logger.info('Clearing all audio translation state');
|
||||
conference.clearTranslation();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SET_AUDIO_TRANSLATION_LANGUAGE: {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (conference) {
|
||||
logger.info(`Setting audio translation language to ${action.language ?? 'off'}`);
|
||||
conference.setReceiverTranslationLanguage(action.language);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE: {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (conference) {
|
||||
logger.info(`Setting audio translation language for ${action.participantId} to ${action.language ?? 'off'}`);
|
||||
conference.setParticipantTranslationLanguage(action.participantId, action.language);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Surfaces audio-translation request failures reported by the bridge-side component: shows a notification and
|
||||
* resets translation state (redux + lib-jitsi-meet) so the UI reflects the failure and re-enabling works.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, { dispatch }, previousConference) => {
|
||||
if (!conference || previousConference) {
|
||||
return;
|
||||
}
|
||||
|
||||
conference.on(JitsiConferenceEvents.AUDIO_TRANSLATION_FAILED,
|
||||
({ endpointIds, error }: { endpointIds: string[]; error: string; }) => {
|
||||
logger.warn(`Audio translation request failed: ${error} for [${endpointIds.join(', ')}]`);
|
||||
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: ERROR_NOTIFICATION_KEYS[error] ?? DEFAULT_ERROR_KEY
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
|
||||
// The optimistic selection didn't take. Reset everything: clears redux (UI reflects off)
|
||||
// and, via conference.clearTranslation(), resets lib-jitsi-meet's language state so
|
||||
// re-enabling isn't deduped to a no-op.
|
||||
dispatch(clearAudioTranslation());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ingests the two per-participant translation signals from the conference: which translated sources the bridge
|
||||
* is forwarding to us (receiving), and which remote participants are translating us (enabled). Both are cleared
|
||||
* when the conference goes away.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, { dispatch, getState }, previousConference) => {
|
||||
if (previousConference) {
|
||||
dispatch(clearReceivingTranslatedSources());
|
||||
dispatch(setTranslationListeners([]));
|
||||
}
|
||||
|
||||
if (!conference) {
|
||||
return;
|
||||
}
|
||||
|
||||
conference.on(JitsiConferenceEvents.TRANSLATED_SOURCE_SENDING_CHANGED,
|
||||
({ sending, sourceName, timestamp }: { sending: boolean; sourceName: string; timestamp: number; }) => {
|
||||
// The bridge currently emits only sending=true, which would leave the receiving state
|
||||
// stuck on; ignore the events unless explicitly enabled.
|
||||
if (!getState()['features/base/config'].audioTranslation?.enableSendingChangeEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateTranslatedSourceSending(sourceName, sending, timestamp));
|
||||
});
|
||||
|
||||
conference.on(JitsiConferenceEvents.AUDIO_TRANSLATION_LISTENERS_CHANGED,
|
||||
(ids: string[]) => {
|
||||
dispatch(setTranslationListeners(ids));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* When a moderator disables audio translation for the room, clear the local user's active translation and notify
|
||||
* them. The room flag only blocks new requests on the bridge; it does not tear down active subscriptions. Clearing
|
||||
* locally drops the synthetic-source Include subscription (so the bridge stops forwarding the translated audio) and
|
||||
* un-ducks the original.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => isAudioTranslationRoomEnabled(state),
|
||||
(roomEnabled, { dispatch, getState }) => {
|
||||
if (roomEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { language, participantLanguages } = getState()['features/audio-translation'];
|
||||
|
||||
if (language || Object.keys(participantLanguages).length > 0) {
|
||||
dispatch(clearAudioTranslation());
|
||||
dispatch(showNotification({
|
||||
titleKey: 'audioTranslation.disabledForRoom'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
}
|
||||
});
|
||||
83
react/features/audio-translation/middleware.native.ts
Normal file
83
react/features/audio-translation/middleware.native.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
|
||||
import { DEFAULT_ORIGINAL_VOLUME } from './constants';
|
||||
import { getDuckedVolumeForParticipant, shouldDuckOriginalAudio } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
import './middleware.any';
|
||||
|
||||
// Playout gain last applied per track id; untouched tracks are left to the volume slider.
|
||||
const appliedVolumes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Ducks original remote audio via _setVolume (native has no per-track audio elements), restoring the
|
||||
* user/full volume afterwards.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @param {boolean} reassertDucked - Re-apply the duck after a slider move overwrote the gain.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _applyDucking({ getState }: IStore, reassertDucked = false): void {
|
||||
const state = getState();
|
||||
const { participantsVolume } = state['features/filmstrip'];
|
||||
const currentIds = new Set<string>();
|
||||
|
||||
for (const track of state['features/base/tracks']) {
|
||||
if (track.local || track.mediaType !== MEDIA_TYPE.AUDIO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const webrtcTrack = track.jitsiTrack?.getTrack?.();
|
||||
const trackId: string | undefined = webrtcTrack?.id;
|
||||
|
||||
if (!webrtcTrack || typeof webrtcTrack._setVolume !== 'function' || !trackId) {
|
||||
continue;
|
||||
}
|
||||
currentIds.add(trackId);
|
||||
|
||||
const sourceName: string | undefined = track.jitsiTrack?.getSourceName?.();
|
||||
const ducked = shouldDuckOriginalAudio(state, sourceName, track.participantId);
|
||||
const userVolume = track.participantId ? participantsVolume[track.participantId] : undefined;
|
||||
const target = ducked
|
||||
? getDuckedVolumeForParticipant(state, track.participantId)
|
||||
: typeof userVolume === 'number' ? userVolume : DEFAULT_ORIGINAL_VOLUME;
|
||||
const applied = appliedVolumes.get(trackId);
|
||||
|
||||
if ((!ducked && applied === undefined)
|
||||
|| (applied === target && !(ducked && reassertDucked))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
webrtcTrack._setVolume(target);
|
||||
appliedVolumes.set(trackId, target);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to set playout volume for track ${trackId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Array.from(appliedVolumes.keys())) {
|
||||
if (!currentIds.has(id)) {
|
||||
appliedVolumes.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/tracks'],
|
||||
(_, store) => _applyDucking(store));
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/audio-translation'].language,
|
||||
(_, store) => _applyDucking(store));
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/audio-translation'].participantLanguages,
|
||||
(_, store) => _applyDucking(store));
|
||||
|
||||
// A slider move writes the gain directly, so re-assert the duck over it.
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/filmstrip'].participantsVolume,
|
||||
(_, store) => _applyDucking(store, true));
|
||||
1
react/features/audio-translation/middleware.web.ts
Normal file
1
react/features/audio-translation/middleware.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './middleware.any';
|
||||
94
react/features/audio-translation/reducer.ts
Normal file
94
react/features/audio-translation/reducer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
CLEAR_AUDIO_TRANSLATION,
|
||||
CLEAR_RECEIVING_TRANSLATED_SOURCES,
|
||||
SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_TRANSLATION_LISTENERS,
|
||||
UPDATE_TRANSLATED_SOURCE_SENDING
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* The redux state of the audio-translation feature.
|
||||
*/
|
||||
export interface IAudioTranslationState {
|
||||
|
||||
/**
|
||||
* The default target language remote audio is translated into, or null when off.
|
||||
*/
|
||||
language: string | null;
|
||||
|
||||
/**
|
||||
* Per-participant target-language overrides (participantId -> language, or null to disable for that
|
||||
* participant). An entry present here takes precedence over {@link language}; an absent entry inherits it.
|
||||
*/
|
||||
participantLanguages: { [participantId: string]: string | null; };
|
||||
|
||||
/**
|
||||
* Translated source names the bridge is currently forwarding to us (i.e. we are hearing them translated).
|
||||
* A source follows the {@code <endpointId>-a<idx>.<lang>} convention; membership is last-write-wins.
|
||||
*/
|
||||
receivingSources: string[];
|
||||
|
||||
/**
|
||||
* Endpoint ids of the remote participants currently translating the local participant's audio, pushed by
|
||||
* the audio-translation component. Drives the per-participant "translation enabled" badge.
|
||||
*/
|
||||
translationListeners: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: IAudioTranslationState = {
|
||||
language: null,
|
||||
participantLanguages: {},
|
||||
receivingSources: [],
|
||||
translationListeners: []
|
||||
};
|
||||
|
||||
ReducerRegistry.register<IAudioTranslationState>(
|
||||
'features/audio-translation',
|
||||
(state = DEFAULT_STATE, action): IAudioTranslationState => {
|
||||
switch (action.type) {
|
||||
case CLEAR_AUDIO_TRANSLATION:
|
||||
return DEFAULT_STATE;
|
||||
case CLEAR_RECEIVING_TRANSLATED_SOURCES:
|
||||
return {
|
||||
...state,
|
||||
receivingSources: []
|
||||
};
|
||||
case SET_AUDIO_TRANSLATION_LANGUAGE:
|
||||
return {
|
||||
...state,
|
||||
language: action.language
|
||||
};
|
||||
case SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE:
|
||||
return {
|
||||
...state,
|
||||
participantLanguages: {
|
||||
...state.participantLanguages,
|
||||
[action.participantId]: action.language
|
||||
}
|
||||
};
|
||||
case SET_TRANSLATION_LISTENERS:
|
||||
return {
|
||||
...state,
|
||||
translationListeners: action.ids
|
||||
};
|
||||
case UPDATE_TRANSLATED_SOURCE_SENDING: {
|
||||
const has = state.receivingSources.includes(action.sourceName);
|
||||
|
||||
if (action.sending === has) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
receivingSources: action.sending
|
||||
? [ ...state.receivingSources, action.sourceName ]
|
||||
: state.receivingSources.filter((source: string) => source !== action.sourceName)
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user