mediasfu-reactnative-expo
Version:
mediasfu-reactnative-expo – Expo-managed React Native WebRTC SDK for video conferencing, webinars, live streaming, broadcast, screen sharing, whiteboard, chat, recording, live subtitles, translation, and AI agent rooms on iOS, Android, and web. Prebuilt r
472 lines (464 loc) • 21.5 kB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Modal, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View, } from 'react-native';
import RNPickerSelect from 'react-native-picker-select';
import { getLanguageName, getCommonLanguages } from 'mediasfu-shared';
import { createThemedPickerSelectStyles, getModalBodyTheme, } from '../../components_modern/core/modalBodyTheme';
import { getModalPosition } from '../../methods/utils/getModalPosition';
const dedupe = (codes) => Array.from(new Set(codes.map((code) => code.toLowerCase())));
const buildLanguageItems = ({ mode, allowedLanguages, blockedLanguages, includeAuto = false, }) => {
const supported = new Map(getCommonLanguages('en').map((option) => [option.code.toLowerCase(), option]));
let codes = mode === 'allowlist' && allowedLanguages?.length
? allowedLanguages.map((entry) => entry.code)
: Array.from(supported.keys());
if (mode === 'blocklist' && blockedLanguages?.length) {
const blocked = new Set(blockedLanguages.map((code) => code.toLowerCase()));
codes = codes.filter((code) => !blocked.has(code.toLowerCase()));
}
if (!includeAuto) {
codes = codes.filter((code) => code.toLowerCase() !== 'auto');
}
return dedupe(codes)
.map((code) => ({
label: getLanguageName(code),
value: code,
}))
.sort((left, right) => left.label.localeCompare(right.label));
};
const areMapsEqual = (left, right) => {
if (left.size !== right.size) {
return false;
}
for (const [key, value] of left) {
if (right.get(key) !== value) {
return false;
}
}
return true;
};
const resolveSpeakerId = (participant) => participant.id || participant.audioID || participant.name;
const TranslationSettingsModal = ({ isTranslationSettingsModalVisible = false, onTranslationSettingsClose, backgroundColor, style, isDarkMode, position = 'center', renderContainer, translationConfig, member, participants = [], audioProducerId, mySpokenLanguage = 'en', mySpokenLanguageEnabled = false, myDefaultOutputLanguage = null, myDefaultListenLanguage = null, listenPreferences = new Map(), updateMySpokenLanguage, updateMySpokenLanguageEnabled, updateMyDefaultOutputLanguage, applyGlobalListenLanguage, applySpeakerListenPreference, clearSpeakerListenPreference, showSubtitlesOnCards = true, updateShowSubtitlesOnCards, roomName, socket, showAlert, }) => {
const [localSpokenLanguage, setLocalSpokenLanguage] = useState(mySpokenLanguage);
const [localSpokenEnabled, setLocalSpokenEnabled] = useState(mySpokenLanguageEnabled);
const [localDefaultOutputLanguage, setLocalDefaultOutputLanguage] = useState(myDefaultOutputLanguage);
const [localDefaultListenLanguage, setLocalDefaultListenLanguage] = useState(myDefaultListenLanguage);
const [localListenPreferences, setLocalListenPreferences] = useState(new Map(listenPreferences));
const [perSpeakerMode, setPerSpeakerMode] = useState(listenPreferences.size > 0 && myDefaultListenLanguage === null);
const [localShowSubtitles, setLocalShowSubtitles] = useState(showSubtitlesOnCards);
const [isSaving, setIsSaving] = useState(false);
const hasInitializedVisibleSession = useRef(false);
useEffect(() => {
if (!isTranslationSettingsModalVisible) {
hasInitializedVisibleSession.current = false;
return;
}
if (hasInitializedVisibleSession.current) {
return;
}
setLocalSpokenLanguage(mySpokenLanguage);
setLocalSpokenEnabled(mySpokenLanguageEnabled);
setLocalDefaultOutputLanguage(myDefaultOutputLanguage);
setLocalDefaultListenLanguage(myDefaultListenLanguage);
setLocalListenPreferences(new Map(listenPreferences));
setPerSpeakerMode(listenPreferences.size > 0 && myDefaultListenLanguage === null);
setLocalShowSubtitles(showSubtitlesOnCards);
hasInitializedVisibleSession.current = true;
}, [
isTranslationSettingsModalVisible,
listenPreferences,
myDefaultListenLanguage,
myDefaultOutputLanguage,
mySpokenLanguage,
mySpokenLanguageEnabled,
showSubtitlesOnCards,
]);
const theme = getModalBodyTheme(isDarkMode);
const themedPickerSelectStyles = createThemedPickerSelectStyles(theme);
const spokenLanguageItems = useMemo(() => buildLanguageItems({
mode: translationConfig?.spokenLanguageMode,
allowedLanguages: translationConfig?.allowedSpokenLanguages,
blockedLanguages: translationConfig?.blockedSpokenLanguages,
includeAuto: translationConfig?.autoDetectSpokenLanguage,
}), [translationConfig]);
const listenLanguageItems = useMemo(() => buildLanguageItems({
mode: translationConfig?.listenLanguageMode,
allowedLanguages: translationConfig?.allowedListenLanguages,
blockedLanguages: translationConfig?.blockedListenLanguages,
}), [translationConfig]);
const speakerRows = useMemo(() => participants.filter((participant) => participant.name &&
participant.name !== member &&
Boolean(resolveSpeakerId(participant))), [member, participants]);
const spokenChangeDisabled = translationConfig?.allowSpokenLanguageChange === false;
const listenChangeDisabled = translationConfig?.allowListenLanguageChange === false;
const hasChanges = localSpokenLanguage !== mySpokenLanguage ||
localSpokenEnabled !== mySpokenLanguageEnabled ||
localDefaultOutputLanguage !== myDefaultOutputLanguage ||
localShowSubtitles !== showSubtitlesOnCards ||
(perSpeakerMode
? myDefaultListenLanguage !== null ||
!areMapsEqual(localListenPreferences, listenPreferences)
: localDefaultListenLanguage !== myDefaultListenLanguage ||
listenPreferences.size > 0);
if (!isTranslationSettingsModalVisible) {
return null;
}
const handleSpeakerPreferenceChange = (speakerId, language) => {
setLocalListenPreferences((prev) => {
const next = new Map(prev);
if (language) {
next.set(speakerId, language);
}
else {
next.delete(speakerId);
}
return next;
});
};
const handleSave = async () => {
const canUpdateTranslation = Boolean(socket && roomName);
const onlySubtitleChanged = localShowSubtitles !== showSubtitlesOnCards &&
localSpokenLanguage === mySpokenLanguage &&
localSpokenEnabled === mySpokenLanguageEnabled &&
localDefaultOutputLanguage === myDefaultOutputLanguage &&
localDefaultListenLanguage === myDefaultListenLanguage &&
areMapsEqual(localListenPreferences, listenPreferences);
if (!canUpdateTranslation && !onlySubtitleChanged) {
showAlert?.({
message: 'Translation settings are unavailable until the room connection is ready.',
type: 'danger',
duration: 3000,
});
return;
}
setIsSaving(true);
try {
if (canUpdateTranslation &&
(localSpokenLanguage !== mySpokenLanguage ||
localSpokenEnabled !== mySpokenLanguageEnabled ||
localDefaultOutputLanguage !== myDefaultOutputLanguage)) {
socket?.emit('translation:setMyLanguage', {
roomName,
language: localSpokenLanguage,
defaultOutputLanguage: localDefaultOutputLanguage,
enabled: localSpokenEnabled,
producerId: audioProducerId,
});
updateMySpokenLanguage?.(localSpokenLanguage);
updateMySpokenLanguageEnabled?.(localSpokenEnabled);
updateMyDefaultOutputLanguage?.(localDefaultOutputLanguage);
}
if (canUpdateTranslation) {
if (!perSpeakerMode) {
for (const [speakerId] of listenPreferences) {
await clearSpeakerListenPreference?.(speakerId);
}
if (localDefaultListenLanguage !== myDefaultListenLanguage ||
listenPreferences.size > 0) {
await applyGlobalListenLanguage?.(localDefaultListenLanguage);
}
}
else {
if (myDefaultListenLanguage !== null) {
await applyGlobalListenLanguage?.(null);
}
for (const [speakerId, language] of localListenPreferences) {
if (listenPreferences.get(speakerId) !== language) {
await applySpeakerListenPreference?.(speakerId, language);
}
}
for (const [speakerId] of listenPreferences) {
if (!localListenPreferences.has(speakerId)) {
await clearSpeakerListenPreference?.(speakerId);
}
}
}
}
if (localShowSubtitles !== showSubtitlesOnCards) {
updateShowSubtitlesOnCards?.(localShowSubtitles);
}
showAlert?.({
message: 'Translation settings saved',
type: 'success',
duration: 2000,
});
onTranslationSettingsClose?.();
}
catch (error) {
console.error('Failed to save translation settings', error);
showAlert?.({
message: 'Failed to save translation settings',
type: 'danger',
duration: 3000,
});
}
finally {
setIsSaving(false);
}
};
const dimensions = {
width: 460,
height: speakerRows.length > 0 ? 720 : 620,
};
const defaultContainer = (<Modal visible={isTranslationSettingsModalVisible} transparent animationType="fade" onRequestClose={onTranslationSettingsClose}>
<View style={[styles.modalOverlay, getModalPosition({ position })]}>
<View style={[
styles.container,
backgroundColor ? { backgroundColor } : null,
{ borderColor: theme.borderColor },
style,
]}>
<View style={[styles.header, { borderBottomColor: theme.dividerColor }]}>
<View style={styles.headerCopy}>
<Text style={[styles.title, { color: theme.textColor }]}>Translation</Text>
<Text style={[styles.subtitle, { color: theme.mutedTextColor }]}>Configure speaking, listening, and subtitle preferences.</Text>
</View>
<TouchableOpacity accessibilityRole="button" onPress={onTranslationSettingsClose} style={[styles.closeButton, { borderColor: theme.borderColor }]}>
<Text style={[styles.closeButtonText, { color: theme.textColor }]}>Close</Text>
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<View style={[
styles.sectionCard,
{
backgroundColor: theme.rowBackgroundColor,
borderColor: theme.borderColor,
},
]}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>Room status</Text>
<Text style={[styles.bodyText, { color: theme.mutedTextColor }]}>
{translationConfig?.supportTranslation === false
? 'Real-time translation is disabled for this room.'
: 'Real-time translation is available in this room.'}
</Text>
</View>
<View style={[
styles.sectionCard,
{
backgroundColor: theme.rowBackgroundColor,
borderColor: theme.borderColor,
},
]}>
<View style={styles.rowBetween}>
<View style={styles.switchCopy}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>Translate my audio</Text>
<Text style={[styles.bodyText, { color: theme.mutedTextColor }]}>Let listeners hear your speech in a translated output language.</Text>
</View>
<Switch value={localSpokenEnabled} onValueChange={setLocalSpokenEnabled} trackColor={{ false: theme.borderColor, true: theme.accentColor }} thumbColor={theme.buttonTextColor}/>
</View>
<Text style={[styles.fieldLabel, { color: theme.textColor }]}>Spoken language</Text>
<RNPickerSelect style={themedPickerSelectStyles} value={localSpokenLanguage} onValueChange={(value) => setLocalSpokenLanguage(value || 'en')} items={spokenLanguageItems} disabled={spokenChangeDisabled} placeholder={{ label: 'Select spoken language', value: null }} useNativeAndroidPickerStyle={false}/>
<Text style={[styles.fieldLabel, { color: theme.textColor }]}>Audience output language</Text>
<RNPickerSelect style={themedPickerSelectStyles} value={localDefaultOutputLanguage} onValueChange={(value) => setLocalDefaultOutputLanguage(value)} items={listenLanguageItems} disabled={!localSpokenEnabled} placeholder={{ label: 'Same as spoken language', value: null }} useNativeAndroidPickerStyle={false}/>
</View>
<View style={[
styles.sectionCard,
{
backgroundColor: theme.rowBackgroundColor,
borderColor: theme.borderColor,
},
]}>
<View style={styles.rowBetween}>
<View style={styles.switchCopy}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>Per-speaker listening</Text>
<Text style={[styles.bodyText, { color: theme.mutedTextColor }]}>Choose one language for everyone, or override specific speakers.</Text>
</View>
<Switch value={perSpeakerMode} onValueChange={setPerSpeakerMode} disabled={listenChangeDisabled} trackColor={{ false: theme.borderColor, true: theme.accentColor }} thumbColor={theme.buttonTextColor}/>
</View>
{!perSpeakerMode ? (<>
<Text style={[styles.fieldLabel, { color: theme.textColor }]}>Listen to everyone in</Text>
<RNPickerSelect style={themedPickerSelectStyles} value={localDefaultListenLanguage} onValueChange={(value) => setLocalDefaultListenLanguage(value)} items={listenLanguageItems} disabled={listenChangeDisabled} placeholder={{ label: 'Original audio', value: null }} useNativeAndroidPickerStyle={false}/>
</>) : (<View style={styles.speakerList}>
{speakerRows.length === 0 ? (<Text style={[styles.bodyText, { color: theme.mutedTextColor }]}>Speaker overrides appear once other participants join.</Text>) : (speakerRows.map((participant) => {
const speakerId = resolveSpeakerId(participant);
const currentValue = localListenPreferences.get(speakerId) || null;
return (<View key={`${speakerId}_${participant.name}`} style={[
styles.speakerRow,
{
backgroundColor: theme.inputBackgroundColor,
borderColor: theme.borderColor,
},
]}>
<View style={styles.speakerCopy}>
<Text style={[styles.speakerName, { color: theme.textColor }]}>
{participant.name}
</Text>
<Text style={[styles.bodyText, { color: theme.mutedTextColor }]}>
{currentValue
? `Translate to ${getLanguageName(currentValue)}`
: 'Original audio'}
</Text>
</View>
<View style={styles.speakerPicker}>
<RNPickerSelect style={themedPickerSelectStyles} value={currentValue} onValueChange={(value) => handleSpeakerPreferenceChange(speakerId, value)} items={listenLanguageItems} disabled={listenChangeDisabled} placeholder={{ label: 'Original audio', value: null }} useNativeAndroidPickerStyle={false}/>
</View>
</View>);
}))}
</View>)}
</View>
<View style={[
styles.sectionCard,
{
backgroundColor: theme.rowBackgroundColor,
borderColor: theme.borderColor,
},
]}>
<View style={styles.rowBetween}>
<View style={styles.switchCopy}>
<Text style={[styles.sectionTitle, { color: theme.textColor }]}>Live subtitles on cards</Text>
<Text style={[styles.bodyText, { color: theme.mutedTextColor }]}>Show the latest translated transcript directly on participant cards.</Text>
</View>
<Switch value={localShowSubtitles} onValueChange={setLocalShowSubtitles} trackColor={{ false: theme.borderColor, true: theme.accentColor }} thumbColor={theme.buttonTextColor}/>
</View>
</View>
</ScrollView>
<View style={[styles.footer, { borderTopColor: theme.dividerColor }]}>
<TouchableOpacity accessibilityRole="button" onPress={onTranslationSettingsClose} style={[styles.secondaryButton, { borderColor: theme.borderColor }]}>
<Text style={[styles.secondaryButtonText, { color: theme.textColor }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity accessibilityRole="button" disabled={!hasChanges || isSaving} onPress={handleSave} style={[
styles.primaryButton,
{
backgroundColor: !hasChanges || isSaving
? theme.borderColor
: theme.buttonBackgroundColor,
},
]}>
<Text style={[styles.primaryButtonText, { color: theme.buttonTextColor }]}>
{isSaving ? 'Saving...' : 'Save'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>);
return renderContainer
? renderContainer({ defaultContainer, dimensions })
: defaultContainer;
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.48)',
},
container: {
width: '92%',
maxWidth: 460,
maxHeight: '88%',
borderRadius: 18,
borderWidth: 1,
overflow: 'hidden',
backgroundColor: '#0f172a',
},
header: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
paddingHorizontal: 20,
paddingVertical: 18,
borderBottomWidth: StyleSheet.hairlineWidth,
},
headerCopy: {
flex: 1,
gap: 4,
},
title: {
fontSize: 20,
fontWeight: '700',
},
subtitle: {
fontSize: 13,
lineHeight: 18,
},
closeButton: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
closeButtonText: {
fontSize: 13,
fontWeight: '600',
},
scrollContent: {
paddingHorizontal: 20,
paddingVertical: 18,
gap: 16,
},
sectionCard: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
gap: 12,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
},
bodyText: {
fontSize: 13,
lineHeight: 18,
},
fieldLabel: {
fontSize: 13,
fontWeight: '600',
marginTop: 2,
},
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
},
switchCopy: {
flex: 1,
gap: 4,
},
speakerList: {
gap: 10,
},
speakerRow: {
borderWidth: 1,
borderRadius: 14,
padding: 12,
gap: 10,
},
speakerCopy: {
gap: 2,
},
speakerName: {
fontSize: 14,
fontWeight: '600',
},
speakerPicker: {
minWidth: 160,
},
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
paddingHorizontal: 20,
paddingVertical: 16,
borderTopWidth: StyleSheet.hairlineWidth,
},
secondaryButton: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
},
secondaryButtonText: {
fontSize: 14,
fontWeight: '600',
},
primaryButton: {
borderRadius: 999,
paddingHorizontal: 18,
paddingVertical: 10,
},
primaryButtonText: {
fontSize: 14,
fontWeight: '700',
},
});
export default TranslationSettingsModal;
//# sourceMappingURL=TranslationSettingsModal.js.map