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
536 lines (532 loc) • 19.3 kB
JavaScript
// AudioCard.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, Animated, Image, Pressable, Platform, } from 'react-native';
import { FontAwesome5 } from "@expo/vector-icons";
import { getOverlayPosition } from '../../methods/utils/getOverlayPosition';
import MiniCard from './MiniCard';
import { controlMedia } from '../../consumers/controlMedia';
/**
* AudioCard - Displays a participant in audio-only mode with waveform and controls
*
* AudioCard is a specialized React Native component for displaying participants in
* audio-only scenarios. It renders an avatar/image with an animated audio waveform,
* participant info overlay, and optional media control buttons. The component is ideal
* for audio-focused events, participants with video off, or compact grid layouts.
*
* **Key Features:**
* - Animated audio waveform visualization based on real-time decibel levels
* - Avatar/image display with optional rounded corners
* - Optional control buttons for muting/video toggling
* - Participant info overlay with customizable positioning
* - Fallback to MiniCard for compact display
* - Responsive layout with custom styling support
*
* **UI Customization - Two-Tier Override System:**
*
* 1. **Custom Render Function** (via `customAudioCard` prop):
* Pass a function that receives all AudioCardOptions and returns custom JSX.
* Provides complete control over rendering logic and appearance.
*
* 2. **Component Override** (via `uiOverrides.audioCardComponent`):
* Replace the entire AudioCard component while preserving MediaSFU's orchestration.
* Useful when you want a different component implementation.
*
* **Advanced Render Overrides:**
* - `renderContent`: Wrap/modify the card's inner content while keeping container
* - `renderContainer`: Wrap/modify the entire card container
*
* @component
* @param {AudioCardOptions} props - Configuration options for the AudioCard component
*
* @returns {JSX.Element} Rendered audio card with waveform, avatar, and controls
*
* @example
* // Basic usage - Display participant audio card with default styling
* import React from 'react';
* import { AudioCard } from 'mediasfu-reactnative-expo';
* import { Socket } from 'socket.io-client';
*
* const socket = Socket('https://example.com');
*
* function AudioParticipantGrid() {
* return (
* <AudioCard
* name="Alice Johnson"
* participant={{
* name: 'Alice Johnson',
* id: '456',
* videoOn: false,
* muted: false,
* audioID: 'audio_123',
* }}
* audioDecibels={{ name: 'Alice Johnson', averageLoudness: 50 }}
* parameters={{
* audioDecibels: [{ name: 'Alice Johnson', averageLoudness: 50 }],
* participants: [{ name: 'Alice Johnson', id: '456', videoOn: false, muted: false }],
* socket,
* coHostResponsibility: [],
* roomName: 'room1',
* coHost: 'coHostId',
* islevel: '1',
* member: '456',
* eventType: 'conference',
* getUpdatedAllParams: () => ({ ...params }),
* }}
* backgroundColor="#2c678f"
* barColor="red"
* textColor="white"
* showControls={true}
* showInfo={true}
* />
* );
* }
*
* @example
* // Custom styled audio card with avatar and custom colors
* <AudioCard
* name="Bob Wilson"
* participant={currentParticipant}
* audioDecibels={{ name: 'Bob Wilson', averageLoudness: 65 }}
* parameters={sessionParams}
* backgroundColor="#1a1a2e"
* barColor="#0f3460"
* textColor="#e94560"
* imageSource="https://example.com/avatar/bob.jpg"
* roundedImage={true}
* showControls={true}
* showInfo={true}
* controlsPosition="bottomRight"
* infoPosition="topLeft"
* customStyle={{ borderRadius: 12, margin: 8, borderWidth: 2, borderColor: '#e94560' }}
* />
*
* @example
* // Custom audio card with custom render function
* import { View, Text, Animated } from 'react-native';
*
* const customAudioCard = (options: AudioCardOptions) => {
* const { name, participant, audioDecibels } = options;
* const volume = audioDecibels?.averageLoudness || 0;
*
* return (
* <View style={{ backgroundColor: '#000', padding: 15, borderRadius: 10 }}>
* <Text style={{ color: '#fff', fontSize: 16, fontWeight: 'bold' }}>{name}</Text>
* <View style={{ marginTop: 10, flexDirection: 'row', alignItems: 'center' }}>
* <Text style={{ color: '#888' }}>Volume: </Text>
* <View style={{ width: volume * 2, height: 5, backgroundColor: volume > 50 ? 'green' : 'yellow' }} />
* </View>
* {participant.muted && (
* <Text style={{ color: 'red', marginTop: 5 }}>🔇 Muted</Text>
* )}
* </View>
* );
* };
*
* <AudioCard
* name="Custom Audio Participant"
* participant={participant}
* audioDecibels={audioData}
* parameters={params}
* customAudioCard={customAudioCard}
* />
*
* @example
* // Using uiOverrides for component-level customization
* import { MyCustomAudioCard } from './MyCustomAudioCard';
*
* const sessionConfig = {
* credentials: { apiKey: 'your-api-key' },
* uiOverrides: {
* audioCardComponent: {
* component: MyCustomAudioCard,
* injectedProps: {
* theme: 'neon',
* showWaveform: true,
* animationSpeed: 'fast',
* },
* },
* },
* };
*
* // MyCustomAudioCard.tsx
* export const MyCustomAudioCard = (props: AudioCardOptions & { theme: string; showWaveform: boolean; animationSpeed: string }) => {
* const waveformColor = props.theme === 'neon' ? '#00ff00' : '#ff0000';
*
* return (
* <View style={{ padding: 10 }}>
* <Text style={{ color: waveformColor }}>{props.name}</Text>
* {props.showWaveform && (
* <Text>Audio Level: {props.audioDecibels?.averageLoudness || 0}</Text>
* )}
* </View>
* );
* };
*
* @example
* ```tsx
* import React from 'react';
* import { AudioCard } from 'mediasfu-reactnative-expo';
* import { io } from 'socket.io-client';
*
* function App() {
* const socket = io('http://localhost:3000');
*
* return (
* <AudioCard
* name="John Doe"
* barColor="blue"
* textColor="white"
* imageSource="https://example.com/image.jpg"
* showControls={true}
* showInfo={true}
* participant={{ name: "John Doe", muted: false, videoOn: true }}
* parameters={{
* audioDecibels: [{ name: "John Doe", averageLoudness: 128 }],
* participants: [{ name: "John Doe" }],
* socket,
* coHostResponsibility: [],
* roomName: "Room 1",
* coHost: "Admin",
* islevel: "1",
* member: "12345",
* eventType: "meeting",
* showAlert: ({ message, type }) => console.log(message, type),
* getUpdatedAllParams: () => ({}),
* }}
* />
* );
* }
*
* export default App;
* ```
*/
const AudioCard = ({ controlUserMedia = controlMedia, customStyle, name, barColor = 'red', textColor = 'white', imageSource, roundedImage = false, imageStyle, showControls = true, showInfo = true, videoInfoComponent, videoControlsComponent, controlsPosition = 'topLeft', infoPosition = 'bottomLeft', participant, backgroundColor, audioDecibels, liveSubtitleText, showSubtitles = true, parameters, customAudioCard, style, renderContent, renderContainer, }) => {
// State for animated waveform bars
const [waveformAnimations] = useState(Array.from({ length: 9 }, () => new Animated.Value(0)));
const [showWaveform, setShowWaveform] = useState(true);
const latestParameters = useCallback(() => parameters.getUpdatedAllParams?.() ?? parameters, [parameters]);
useEffect(() => {
// Interval to check audio decibels and participant status every second
const interval = setInterval(() => {
const { audioDecibels, participants } = latestParameters();
const existingEntry = audioDecibels?.find((entry) => entry.name === name);
const updatedParticipant = participants?.find((p) => p.name === name);
// Conditions to animate or reset waveform
if (existingEntry &&
existingEntry.averageLoudness > 127.5 &&
updatedParticipant &&
!updatedParticipant.muted) {
animateWaveform();
}
else {
resetWaveform();
}
}, 1000);
return () => clearInterval(interval);
}, [audioDecibels, latestParameters, name]);
useEffect(() => {
if (participant?.muted) {
setShowWaveform(false);
resetWaveform();
}
else {
setShowWaveform(true);
}
}, [participant?.muted]);
/**
* animateWaveform - Starts the animation for each waveform bar.
*/
const animateWaveform = () => {
const animations = waveformAnimations.map((animation, index) => Animated.loop(Animated.sequence([
Animated.timing(animation, {
toValue: 1,
duration: getAnimationDuration(index),
useNativeDriver: false,
}),
Animated.timing(animation, {
toValue: 0,
duration: getAnimationDuration(index),
useNativeDriver: false,
}),
])));
Animated.parallel(animations).start();
};
/**
* resetWaveform - Resets all waveform animations.
*/
const resetWaveform = () => {
waveformAnimations.forEach((animation) => animation.stopAnimation());
waveformAnimations.forEach((animation) => animation.setValue(0));
};
/**
* getAnimationDuration - Returns the duration for a given waveform bar index.
* @param index - The index of the waveform bar.
* @returns The duration in milliseconds.
*/
const getAnimationDuration = (index) => {
const durations = [474, 433, 407, 458, 400, 427, 441, 419, 487];
return durations[index] || 500;
};
/**
* toggleAudio - Toggles the audio state of the participant.
*/
const toggleAudio = async () => {
if (!participant?.muted) {
const updatedParams = latestParameters();
await controlUserMedia({
participantId: participant.id || '',
participantName: participant.name,
type: 'audio',
socket: updatedParams.socket,
coHostResponsibility: updatedParams.coHostResponsibility,
roomName: updatedParams.roomName,
showAlert: updatedParams.showAlert,
coHost: updatedParams.coHost,
islevel: updatedParams.islevel,
member: updatedParams.member,
participants: updatedParams.participants,
});
}
};
/**
* toggleVideo - Toggles the video state of the participant.
*/
const toggleVideo = async () => {
if (participant?.videoOn) {
const updatedParams = latestParameters();
await controlUserMedia({
participantId: participant.id || '',
participantName: participant.name,
type: 'video',
socket: updatedParams.socket,
coHostResponsibility: updatedParams.coHostResponsibility,
roomName: updatedParams.roomName,
showAlert: updatedParams.showAlert,
coHost: updatedParams.coHost,
islevel: updatedParams.islevel,
member: updatedParams.member,
participants: updatedParams.participants,
});
}
};
/**
* renderControls - Renders the control buttons for audio and video.
* @returns The control buttons JSX.Element or a custom component.
*/
const renderControls = () => {
if (!showControls) {
return null;
}
// Use custom videoControlsComponent if provided
if (videoControlsComponent) {
return <>{videoControlsComponent}</>;
}
// Default controls
return (<View style={styles.overlayControls}>
<Pressable style={styles.controlButton} onPress={toggleAudio}>
<FontAwesome5 name={participant?.muted ? 'microphone-slash' : 'microphone'} size={14} color={participant?.muted ? 'red' : 'green'}/>
</Pressable>
<Pressable style={styles.controlButton} onPress={toggleVideo}>
<FontAwesome5 name={participant?.videoOn ? 'video' : 'video-slash'} size={14} color={participant?.videoOn ? 'green' : 'red'}/>
</Pressable>
</View>);
};
const dimensions = { width: 0, height: 0 };
const defaultContent = (<>
{customAudioCard ? (customAudioCard({
name,
barColor: barColor || 'red',
textColor: textColor || 'white',
imageSource,
roundedImage,
imageStyle,
parameters,
})) : (<>
{imageSource ? (<Image source={{ uri: imageSource }} style={[
styles.backgroundImage,
roundedImage ? styles.roundedImage : undefined,
imageStyle,
]} resizeMode="cover"/>) : (<MiniCard initials={name} name={name} fontSize={20} textColor={textColor} showAudioIcon={!participant?.muted} showVideoIcon={Boolean(participant?.videoOn || participant?.videoID)} customStyle={{
borderWidth: parameters.eventType !== 'broadcast' ? 2 : 0,
borderColor: parameters.eventType !== 'broadcast' ? 'black' : 'transparent',
}}/>)}
{/* Participant Info and Waveform */}
{videoInfoComponent ||
(showInfo && (<View style={[
getOverlayPosition({ position: infoPosition }),
Platform.OS === 'web'
? showControls
? styles.overlayWeb
: styles.overlayWebAlt
: styles.overlayMobile,
]}>
<View style={styles.nameColumn}>
<Text style={[styles.nameText, { color: textColor }]}>
{name}
</Text>
</View>
{showWaveform && (<View style={Platform.OS === 'web'
? styles.waveformWeb
: styles.waveformMobile}>
{waveformAnimations.map((animation, index) => (<Animated.View key={index} style={[
styles.bar,
{
height: animation.interpolate({
inputRange: [0, 1],
outputRange: [1, 14],
}),
backgroundColor: barColor,
},
]}/>))}
</View>)}
</View>))}
{/* Control Buttons */}
{showSubtitles && liveSubtitleText ? (<View style={styles.subtitleContainer}>
<Text style={styles.subtitleText} numberOfLines={2}>
{liveSubtitleText}
</Text>
</View>) : null}
{videoControlsComponent ||
(showControls && (<View style={[
styles.overlayControls,
getOverlayPosition({ position: controlsPosition }),
]}>
{renderControls()}
</View>))}
</>)}
</>);
const content = renderContent
? renderContent({ defaultContent, dimensions })
: defaultContent;
const defaultContainer = (<View style={[
styles.card,
customStyle,
{ backgroundColor: backgroundColor || '#2c678f' },
style,
]}>
{content}
</View>);
return renderContainer
? renderContainer({ defaultContainer, dimensions })
: defaultContainer;
};
export default AudioCard;
// Stylesheet with TypeScript typings
const styles = StyleSheet.create({
card: {
width: '100%',
height: '100%',
margin: 0,
padding: 0,
backgroundColor: '#2c678f',
borderWidth: 2,
borderColor: 'black',
position: 'relative',
},
backgroundImage: {
width: 80,
height: 80,
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'center',
top: '50%',
left: '50%',
transform: [
{ translateY: -40 }, // Half of the height
{ translateX: -40 }, // Half of the width
],
},
roundedImage: {
borderRadius: 16,
},
overlayMobile: {
position: 'absolute',
width: 'auto',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
},
overlayWeb: {
position: 'absolute',
width: 'auto',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
},
overlayWebAlt: {
position: 'absolute',
width: 'auto',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
},
overlayControls: {
flexDirection: 'row',
paddingVertical: 0,
paddingHorizontal: 0,
position: 'absolute',
},
controlButton: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingVertical: 5,
paddingHorizontal: 5,
marginEnd: 5,
fontSize: 12,
borderRadius: 4,
},
nameColumn: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingVertical: 5,
paddingHorizontal: 5,
marginEnd: 2,
fontSize: 12,
},
nameText: {
fontSize: 12,
fontWeight: 'bold',
},
waveformWeb: {
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.05)',
padding: 0,
flexDirection: 'row',
minHeight: '4%',
maxHeight: '70%',
width: '100%',
},
waveformMobile: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.05)',
paddingVertical: 5,
marginLeft: 5,
maxWidth: '25%',
},
bar: {
flex: 1,
opacity: 0.7,
marginHorizontal: 1,
},
subtitleContainer: {
position: 'absolute',
left: 8,
right: 8,
bottom: 8,
backgroundColor: 'rgba(0,0,0,0.65)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
subtitleText: {
color: 'white',
fontSize: 12,
textAlign: 'center',
},
});
//# sourceMappingURL=AudioCard.js.map