snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:audio-translation');

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

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

View File

@@ -0,0 +1 @@
import './middleware.any';

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