UNPKG

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

324 lines (319 loc) 13.7 kB
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, View, } from 'react-native'; import { FontAwesome5 } from '@expo/vector-icons'; import { getOverlayPosition } from '../../methods/utils/getOverlayPosition'; import { controlMedia } from '../../consumers/controlMedia'; import { getModernColors, modernShadow, resolveIsDarkMode } from '../core/modernTheme'; import { ModernMiniCard } from './ModernMiniCard'; import { SubtitleOverlay } from './SubtitleOverlay'; const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); export const ModernAudioCard = (props) => { const { controlUserMedia = controlMedia, customStyle, name, barColor, textColor, imageSource, roundedImage = true, imageStyle, showControls = true, showInfo = true, videoInfoComponent, videoControlsComponent, controlsPosition = 'topLeft', infoPosition = 'bottomLeft', participant, backgroundColor, audioDecibels, liveSubtitleText, showSubtitles = true, parameters, customAudioCard, style, renderContent, renderContainer, } = props; const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [isSpeaking, setIsSpeaking] = useState(false); const [waveformValues, setWaveformValues] = useState([3, 3, 3, 3, 3]); const darkMode = resolveIsDarkMode(props); const colors = getModernColors(darkMode); const resolvedBackgroundColor = backgroundColor && backgroundColor !== 'transparent' ? backgroundColor : colors.mediaTileAlt; const resolvedBarColor = !barColor || barColor.toLowerCase() === 'red' ? colors.accentAlt : barColor; const resolvedTextColor = textColor ?? colors.invertedText; const avatarSize = useMemo(() => { if (!dimensions.width || !dimensions.height) { return 120; } return clamp(Math.min(dimensions.width, dimensions.height) * 0.3, 92, 150); }, [dimensions.height, dimensions.width]); const badgeIconSize = useMemo(() => clamp(Math.round(avatarSize * 0.13), 11, 15), [avatarSize]); const displayName = useMemo(() => name.trim().slice(0, 24), [name]); const handleLayout = useCallback((event) => { const { width, height } = event.nativeEvent.layout; setDimensions((current) => { if (current.width === width && current.height === height) { return current; } return { width, height }; }); }, []); useEffect(() => { const resolveSpeakingState = () => { const latestAudioDecibels = parameters?.getUpdatedAllParams?.()?.audioDecibels; const activeAudioEntry = audioDecibels ?? latestAudioDecibels?.find((entry) => entry.name === name || entry.name === participant?.name); const averageLoudness = activeAudioEntry?.averageLoudness ?? 0; setIsSpeaking(averageLoudness > 127.5 && !participant?.muted); }; resolveSpeakingState(); const interval = setInterval(resolveSpeakingState, 900); return () => clearInterval(interval); }, [audioDecibels, name, parameters, participant?.muted, participant?.name]); useEffect(() => { if (!isSpeaking) { setWaveformValues([3, 3, 3, 3, 3]); return undefined; } const interval = setInterval(() => { setWaveformValues([ clamp(Math.round(Math.random() * 16) + 4, 4, 22), clamp(Math.round(Math.random() * 12) + 6, 4, 22), clamp(Math.round(Math.random() * 18) + 4, 4, 24), clamp(Math.round(Math.random() * 12) + 6, 4, 22), clamp(Math.round(Math.random() * 16) + 4, 4, 22), ]); }, 150); return () => clearInterval(interval); }, [isSpeaking]); const invokeMediaControl = useCallback(async (type) => { if (!controlUserMedia || !participant?.id || !parameters?.getUpdatedAllParams) { return; } const updatedParams = parameters.getUpdatedAllParams(); await controlUserMedia({ participantId: participant.id, participantName: participant.name, type, socket: updatedParams.socket, roomName: updatedParams.roomName, coHostResponsibility: updatedParams.coHostResponsibility, showAlert: updatedParams.showAlert, coHost: updatedParams.coHost, participants: updatedParams.participants, member: updatedParams.member, islevel: updatedParams.islevel, }); }, [controlUserMedia, parameters, participant]); const handleToggleAudio = useCallback(async () => { if (!participant?.muted) { await invokeMediaControl('audio'); } }, [invokeMediaControl, participant?.muted]); const handleToggleVideo = useCallback(async () => { if (participant?.videoOn || participant?.videoID) { await invokeMediaControl('video'); } }, [invokeMediaControl, participant?.videoID, participant?.videoOn]); const defaultContent = customAudioCard ? (customAudioCard({ ...props, barColor: resolvedBarColor, textColor: resolvedTextColor, backgroundColor: resolvedBackgroundColor, })) : (<View style={styles.contentShell} onLayout={handleLayout}> <View style={styles.avatarZone}> {isSpeaking ? (<View pointerEvents="none" style={[ styles.speakingHalo, { width: avatarSize + 28, height: avatarSize + 28, borderRadius: (avatarSize + 28) / 2, borderColor: resolvedBarColor, backgroundColor: 'transparent', }, ]}/>) : null} <View style={{ width: avatarSize, height: avatarSize }}> <ModernMiniCard initials={name} name={name} imageSource={imageSource} imageStyle={imageStyle} roundedImage={roundedImage} fontSize={Math.max(16, Math.round(avatarSize * 0.22))} isDarkMode={darkMode} size={avatarSize} showGradientBackground showBorder/> </View> </View> <View style={styles.statusCluster}> <View style={[ styles.statusBadge, { backgroundColor: participant?.muted ? 'rgba(127, 29, 29, 0.66)' : 'rgba(8, 145, 178, 0.40)', borderColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : colors.border, }, ]}> <FontAwesome5 name={participant?.muted ? 'microphone-slash' : 'microphone'} size={badgeIconSize} color={resolvedTextColor}/> </View> <View style={[ styles.statusBadge, styles.statusBadgeSpacer, { backgroundColor: participant?.videoOn || participant?.videoID ? 'rgba(15, 118, 110, 0.38)' : 'rgba(71, 85, 105, 0.44)', borderColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : colors.border, }, ]}> <FontAwesome5 name={participant?.videoOn || participant?.videoID ? 'video' : 'video-slash'} size={badgeIconSize} color={resolvedTextColor}/> </View> </View> {(videoInfoComponent || showInfo) && (<View style={[ styles.infoOverlay, getOverlayPosition({ position: infoPosition }), { backgroundColor: darkMode ? 'rgba(2, 6, 23, 0.68)' : 'rgba(255, 255, 255, 0.92)', borderColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : colors.border, }, ]}> {videoInfoComponent ? (videoInfoComponent) : (<> {isSpeaking ? (<View style={[styles.speakingDot, { backgroundColor: colors.success }]}/>) : null} <Text style={[styles.nameText, { color: resolvedTextColor }]} numberOfLines={1}> {displayName} </Text> <View style={styles.waveformRow}> {waveformValues.map((value, index) => (<View key={`${displayName}-wave-${index}`} style={[ styles.waveformBar, { height: isSpeaking ? value : 3, backgroundColor: resolvedBarColor, }, ]}/>))} </View> </>)} </View>)} {(videoControlsComponent || showControls) && (<View style={[styles.controlsOverlay, getOverlayPosition({ position: controlsPosition })]}> {videoControlsComponent ? (videoControlsComponent) : (<> <Pressable accessibilityRole="button" accessibilityLabel={participant?.muted ? 'Participant microphone is muted' : 'Mute participant microphone'} disabled={participant?.muted} onPress={handleToggleAudio} style={({ pressed }) => [ styles.controlButton, { backgroundColor: pressed ? 'rgba(15, 23, 42, 0.82)' : darkMode ? 'rgba(15, 23, 42, 0.72)' : 'rgba(255, 255, 255, 0.90)', borderColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : colors.border, opacity: participant?.muted ? 0.7 : 1, }, ]}> <FontAwesome5 name={participant?.muted ? 'microphone-slash' : 'microphone'} size={14} color={participant?.muted ? colors.danger : resolvedTextColor}/> </Pressable> <Pressable accessibilityRole="button" accessibilityLabel={participant?.videoOn || participant?.videoID ? 'Turn participant camera off' : 'Participant camera is off'} disabled={!participant?.videoOn && !participant?.videoID} onPress={handleToggleVideo} style={({ pressed }) => [ styles.controlButton, styles.controlButtonSpacer, { backgroundColor: pressed ? 'rgba(15, 23, 42, 0.82)' : darkMode ? 'rgba(15, 23, 42, 0.72)' : 'rgba(255, 255, 255, 0.90)', borderColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : colors.border, opacity: participant?.videoOn || participant?.videoID ? 1 : 0.7, }, ]}> <FontAwesome5 name={participant?.videoOn || participant?.videoID ? 'video' : 'video-slash'} size={14} color={participant?.videoOn || participant?.videoID ? resolvedTextColor : colors.danger}/> </Pressable> </>)} </View>)} <SubtitleOverlay speakerId={participant?.id || ''} speakerName={participant?.name || name || ''} fallbackText={liveSubtitleText} showSubtitles={showSubtitles} isDarkMode={darkMode}/> </View>); const content = renderContent ? renderContent({ defaultContent, dimensions }) : defaultContent; const defaultContainer = (<View style={[ styles.card, modernShadow, { backgroundColor: resolvedBackgroundColor, borderColor: colors.border, }, customStyle, style, ]}> {content} </View>); return renderContainer ? renderContainer({ defaultContainer, dimensions }) : defaultContainer; }; const styles = StyleSheet.create({ card: { width: '100%', height: '100%', borderRadius: 18, borderWidth: 1, overflow: 'hidden', position: 'relative', }, contentShell: { flex: 1, minWidth: 0, minHeight: 0, alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden', }, avatarZone: { alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 2, }, speakingHalo: { position: 'absolute', borderWidth: 1.5, }, statusCluster: { position: 'absolute', top: 12, right: 12, zIndex: 3, }, statusBadge: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center', borderWidth: 1, }, statusBadgeSpacer: { marginTop: 8, }, infoOverlay: { position: 'absolute', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, maxWidth: '72%', zIndex: 3, }, speakingDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8, }, nameText: { fontSize: 13, fontWeight: '700', maxWidth: 160, }, waveformRow: { flexDirection: 'row', alignItems: 'flex-end', marginLeft: 10, minWidth: 30, height: 20, }, waveformBar: { width: 3, marginLeft: 3, borderRadius: 999, }, controlsOverlay: { position: 'absolute', flexDirection: 'row', zIndex: 3, }, controlButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, }, controlButtonSpacer: { marginLeft: 8, }, }); export default ModernAudioCard; //# sourceMappingURL=ModernAudioCard.js.map