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

302 lines
12 KiB
TypeScript

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