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

516 lines (513 loc) 17.8 kB
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