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
516 lines (513 loc) • 17.8 kB
JavaScript
import React, { useState, useEffect } from "react";
import { View, Text, StyleSheet, Animated, Pressable, Platform, } from "react-native";
import { FontAwesome5 } from "@expo/vector-icons";
import { controlMedia } from "../../consumers/controlMedia";
import { getOverlayPosition } from "../../methods/utils/getOverlayPosition";
import { SubtitleOverlay } from "../../components_modern/display_components/SubtitleOverlay";
import CardVideoDisplay from "./CardVideoDisplay";
/**
* VideoCard - Displays a participant's video stream with controls, info overlay, and audio waveform
*
* VideoCard is a comprehensive React Native component that renders a participant's video feed
* with real-time audio visualization (animated waveform), optional media controls, and participant
* information overlay. It supports multiple display modes, custom styling, and flexible positioning.
*
* **Key Features:**
* - Real-time video stream rendering with MediaStream support
* - Animated audio waveform visualization based on decibel levels
* - Optional control buttons for muting/video toggling
* - Participant info overlay with customizable positioning
* - Avatar fallback when video is off
* - Horizontal mirroring support
* - Responsive layout with force fullscreen mode
*
* **UI Customization - Two-Tier Override System:**
*
* 1. **Custom Render Function** (via `customVideoCard` prop):
* Pass a function that receives all VideoCardOptions and returns custom JSX.
* Provides complete control over rendering logic and appearance.
*
* 2. **Component Override** (via `uiOverrides.videoCardComponent`):
* Replace the entire VideoCard 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 {VideoCardOptions} props - Configuration options for the VideoCard component
*
* @returns {JSX.Element} Rendered video card with stream, controls, and waveform
*
* @example
* // Basic usage - Display participant video with default styling
* import React from 'react';
* import { VideoCard } from 'mediasfu-reactnative-expo';
* import { Socket } from 'socket.io-client';
*
* const socket = Socket('https://example.com');
*
* function ParticipantGrid() {
* return (
* <VideoCard
* name="John Doe"
* remoteProducerId="producer_123"
* eventType="conference"
* forceFullDisplay={false}
* videoStream={participantStream}
* participant={{
* name: 'John Doe',
* id: '123',
* videoOn: true,
* muted: false,
* }}
* parameters={{
* socket,
* roomName: 'room1',
* coHostResponsibility: [],
* audioDecibels: [],
* participants: [{ name: 'John Doe', id: '123', videoOn: true, muted: false }],
* member: '123',
* islevel: '1',
* coHost: 'coHostId',
* getUpdatedAllParams: () => ({ ...params }),
* }}
* backgroundColor="#2c678f"
* showControls={true}
* showInfo={true}
* barColor="white"
* textColor="white"
* />
* );
* }
*
* @example
* // Custom styled video card with mirroring and custom colors
* <VideoCard
* name="Jane Smith"
* remoteProducerId="producer_456"
* eventType="webinar"
* forceFullDisplay={true}
* videoStream={selfStream}
* participant={currentParticipant}
* parameters={sessionParams}
* backgroundColor="#1a1a2e"
* barColor="#16213e"
* textColor="#eaeaea"
* doMirror={true}
* showControls={true}
* showInfo={true}
* controlsPosition="bottomRight"
* infoPosition="topLeft"
* customStyle={{ borderRadius: 12, margin: 8 }}
* />
*
* @example
* // Custom video card with custom render function
* import { View, Text } from 'react-native';
*
* const customVideoCard = (options: VideoCardOptions) => {
* const { name, participant, videoStream } = options;
*
* return (
* <View style={{ backgroundColor: '#000', padding: 10 }}>
* <Text style={{ color: '#fff', fontSize: 18 }}>{name}</Text>
* {videoStream ? (
* <Text>Video Active</Text>
* ) : (
* <Text>Video Off</Text>
* )}
* {participant.muted && <Text style={{ color: 'red' }}>Muted</Text>}
* </View>
* );
* };
*
* <VideoCard
* name="Custom Participant"
* remoteProducerId="producer_789"
* eventType="broadcast"
* forceFullDisplay={false}
* videoStream={stream}
* participant={participant}
* parameters={params}
* customVideoCard={customVideoCard}
* />
*
* @example
* // Using uiOverrides for component-level customization
* import { MyCustomVideoCard } from './MyCustomVideoCard';
*
* const sessionConfig = {
* credentials: { apiKey: 'your-api-key' },
* uiOverrides: {
* videoCardComponent: {
* component: MyCustomVideoCard,
* injectedProps: {
* theme: 'dark',
* showBorder: true,
* },
* },
* },
* };
*
* // MyCustomVideoCard.tsx
* export const MyCustomVideoCard = (props: VideoCardOptions & { theme: string; showBorder: boolean }) => {
* return (
* <View style={{
* borderWidth: props.showBorder ? 2 : 0,
* borderColor: props.theme === 'dark' ? '#fff' : '#000',
* }}>
* <Text>{props.name}</Text>
* {props.videoStream && <Text>Streaming...</Text>}
* </View>
* );
* };
*
* export default App;
* ```
*/
const VideoCard = ({ customStyle, name, barColor = "red", textColor = "white", remoteProducerId, eventType, forceFullDisplay, videoStream, showControls = true, showInfo = true, videoInfoComponent, videoControlsComponent, controlsPosition = "topLeft", infoPosition = "topRight", participant, backgroundColor = "#2c678f", audioDecibels = [], doMirror = false, liveSubtitleText, showSubtitles = true, parameters, customVideoCard, style, renderContent, renderContainer, }) => {
// Initialize waveform animation values
const [waveformAnimations] = useState(Array.from({ length: 9 }, () => new Animated.Value(0)));
const [showWaveform, setShowWaveform] = useState(true);
/**
* animateWaveform - Animates the waveform bars using the Animated API.
*/
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 to their initial state.
*/
const resetWaveform = () => {
waveformAnimations.forEach((animation) => animation.stopAnimation());
waveformAnimations.forEach((animation) => animation.setValue(0));
};
/**
* getAnimationDuration - Retrieves the duration for a specific waveform animation.
*
* @param {number} index - The index of the waveform bar.
* @returns {number} The duration in milliseconds.
*/
const getAnimationDuration = (index) => {
const durations = [474, 433, 407, 458, 400, 427, 441, 419, 487];
return durations[index] || 400;
};
/**
* Effect to handle waveform animations based on audio decibel levels.
*/
useEffect(() => {
const interval = setInterval(() => {
const updatedParams = parameters.getUpdatedAllParams();
const { audioDecibels, participants } = updatedParams;
const existingEntry = audioDecibels.find((entry) => entry.name === name);
const participantEntry = participants.find((p) => p.name === name);
if (existingEntry &&
existingEntry.averageLoudness > 127.5 &&
participantEntry &&
!participantEntry.muted) {
animateWaveform();
}
else {
resetWaveform();
}
}, 1000);
return () => clearInterval(interval);
}, [audioDecibels, name, parameters]);
/**
* Effect to show or hide the waveform based on the participant's muted state.
*/
useEffect(() => {
if (participant?.muted) {
resetWaveform();
setShowWaveform(false);
}
else {
animateWaveform();
setShowWaveform(true);
}
}, [participant?.muted]);
/**
* toggleAudio - Toggles the audio state for the participant.
*/
const toggleAudio = async () => {
if (!participant?.muted) {
const updatedParams = parameters.getUpdatedAllParams();
await controlMedia({
participantId: participant.id || "",
participantName: participant.name,
type: "audio",
socket: updatedParams.socket,
roomName: updatedParams.roomName,
coHostResponsibility: updatedParams.coHostResponsibility,
showAlert: updatedParams.showAlert,
coHost: updatedParams.coHost,
participants: updatedParams.participants,
member: updatedParams.member,
islevel: updatedParams.islevel,
});
}
};
/**
* toggleVideo - Toggles the video state for the participant.
*/
const toggleVideo = async () => {
if (participant?.videoOn) {
const updatedParams = parameters.getUpdatedAllParams();
await controlMedia({
participantId: participant.id || "",
participantName: participant.name,
type: "video",
socket: updatedParams.socket,
roomName: updatedParams.roomName,
coHostResponsibility: updatedParams.coHostResponsibility,
showAlert: updatedParams.showAlert,
coHost: updatedParams.coHost,
participants: updatedParams.participants,
member: updatedParams.member,
islevel: updatedParams.islevel,
});
}
};
/**
* renderControls - Render video controls based on conditions.
* @returns {React.Component} - Rendered video controls.
*/
const renderControls = () => {
if (!showControls) {
return null;
}
if (videoControlsComponent) {
return <>{videoControlsComponent}</>;
}
// Default controls
return (<View style={{
...styles.overlayControls,
...getOverlayPosition({ position: controlsPosition }),
}}>
<Pressable style={styles.controlButton} onPress={toggleAudio}>
<FontAwesome5 name={participant?.muted ? "microphone-slash" : "microphone"} size={16} color={participant?.muted ? "red" : "green"}/>
</Pressable>
<Pressable style={styles.controlButton} onPress={toggleVideo}>
<FontAwesome5 name={participant?.videoOn ? "video" : "video-slash"} size={16} color={participant?.videoOn ? "green" : "red"}/>
</Pressable>
</View>);
};
/**
* renderInfo - Renders the participant information and waveform.
*
* @returns {JSX.Element | null} The rendered info or null.
*/
const renderInfo = () => {
if (videoInfoComponent) {
return <>{videoInfoComponent}</>;
}
if (!showInfo)
return null;
return (<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 }]}>
{participant?.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>);
};
const dimensions = { width: 100, height: 100 };
const defaultContent = (<>
{customVideoCard ? (customVideoCard({
participant,
stream: videoStream,
width: 100,
height: 100,
doMirror,
showControls,
showInfo,
name,
backgroundColor,
parameters,
})) : (<>
{/* Video Display */}
<CardVideoDisplay remoteProducerId={remoteProducerId} eventType={eventType} forceFullDisplay={forceFullDisplay} videoStream={videoStream} backgroundColor={backgroundColor} doMirror={doMirror}/>
{/* Participant Information */}
{renderInfo()}
{/* Video Controls */}
{renderControls()}
{/* Subtitle Overlay */}
<SubtitleOverlay speakerId={participant?.id || ''} speakerName={participant?.name || name || ''} fallbackText={liveSubtitleText} showSubtitles={showSubtitles} isDarkMode={backgroundColor !== '#ffffff' && backgroundColor !== 'white'}/>
</>)}
</>);
const content = renderContent
? renderContent({ defaultContent, dimensions })
: defaultContent;
const defaultContainer = (<View style={[styles.card, customStyle, { backgroundColor }, style]}>
{content}
</View>);
return renderContainer
? renderContainer({ defaultContainer, dimensions })
: defaultContainer;
};
export default VideoCard;
/**
* Stylesheet for the VideoCard component.
*/
const styles = StyleSheet.create({
card: {
width: "100%",
height: "100%",
margin: 0,
padding: 0,
backgroundColor: "#2c678f",
position: "relative",
borderWidth: 2,
borderColor: "black",
borderStyle: "solid",
},
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',
},
overlayWeb: {
position: "absolute",
width: "auto",
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
},
overlayWebAlt: {
position: "absolute",
width: "auto",
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
},
overlayMobile: {
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,
marginRight: 5,
borderRadius: 5,
},
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",
textAlign: "center",
},
waveformContainer: {
flexDirection: "row",
alignItems: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.05)",
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 5,
marginTop: 5,
},
waveformMobile: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.05)",
paddingVertical: 5,
marginLeft: 5,
maxWidth: "25%",
},
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%",
},
bar: {
flex: 1,
opacity: 0.7,
marginHorizontal: 1,
},
backgroundImage: {
position: "absolute",
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: 40, // Fully rounded for a 80x80 image
},
});
//# sourceMappingURL=VideoCard.js.map