SendBird Calls – example in React
CHALLENGE: use peer-to-peer video and voice calls in React
SOLUTION: utilize the sendbird-calls NPM package
In 2020, SendBird added video and voice capabilities to their API. SendBird Calls is a toolbox that provides methods to make or receive a call. You can also mute your microphone or disable the camera. Regarding pricing, all calls are billed per minute.
We’re going to implement Video/Voice calls in our Application using a Peer-to-peer connection (Direct Call). One user will be able to call another user. Regarding video quality, it offers 24 FPS (Frames Per Second) and 1280 x 720, standard HD resolution. This article was inspired by a Community post published here: https://community.sendbird.com/t/sendbird-calls-react-js-single-component-audio-only/91
Sendbird-calls
Sendbird Calls enables real-time calls between users within a Sendbird application. To make a direct voice or video call, the caller specifies user ID and dials. Upon dialing, all of the callee’s authenticated devices will receive notifications about an incoming call. The callee can then choose to accept or decline the call from any of the devices. When the call is accepted, a connection is established between the devices of the caller and the callee.
SBCalls in React
Our main dependency will be sendbird-calls, for styling we will add the MUI Material:
npm install sendbird-calls npm install @mui/material @emotion/react @emotion/styled npm install @mui/icons-material
Make sure to change the APP_ID variable to your own SendBird Application ID.
// src/App.jsx import React, {useState} from 'react'; import './App.css'; import SBCall from "./Calls"; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; const APP_ID = "ABC"; function App() { const [initAction, setInitAction] = useState(null); const queryParams = new URLSearchParams(window.location.search); const USER_ID = queryParams.get('user_id'); let button1Color = "inherit"; let button2Color = "inherit"; if (initAction === 'initVideo') { button1Color = "primary"; } if (initAction === 'initVoice') { button2Color = "primary"; } return ( <div className="App"> <p>MY user ID: {USER_ID}</p> <Box display="flex" justifyContent="center" alignItems="center" > <Button sx={{margin: 2}} onClick={() => setInitAction("initVideo")} variant="contained" color={button1Color}>Init Video Call</Button> <Button sx={{margin: 2}} onClick={() => setInitAction("initVoice")} variant="contained" color={button2Color}>Init Voice Call</Button> </Box> {initAction === "initVideo" ? <SBCall appId={APP_ID} userId={USER_ID} initAction="initVideo"/> : ''} {initAction === "initVoice" ? <SBCall appId={APP_ID} userId={USER_ID} initAction="initVoice"/> : ''} </div> ); } export default App;
And some basic styles for the container:
/** src/App.css */ body { padding:0 20px; } .App { text-align: center; max-width: 500px; border: 5px solid orangered; padding: 3rem; margin: 20px auto; }
The Main React Component, responsible for authentication, establishing a direct call (video or audio):
// src/Calls/index.js import './index.css'; import React from "react"; import SendBirdCall from "sendbird-calls"; import {callStates} from "./settings"; import { IconButton as MIconButton } from "@mui/material"; import { Call as CallIcon } from "@mui/icons-material"; import { CallEnd as CallEndIcon } from "@mui/icons-material"; import { VolumeOff as VolumeOffIcon } from "@mui/icons-material"; import { VolumeUp as VolumeUpIcon } from "@mui/icons-material"; import { Videocam as VideocamIcon } from "@mui/icons-material"; import { VideocamOff as VideocamOffIcon } from "@mui/icons-material"; class SBCall extends React.Component { constructor(props) { super(props); this.state = { appId: props.appId, userId: props.userId, targetUserId: '', info: "Waiting...", call: "", displayPickup: false, displayEnd: false, displayCall: true, listenerId: 'ct-listener-1', errorMsg: '', initAction: props.initAction, isMuted: false, videoHidden: false }; } componentDidMount() { SendBirdCall.init(this.state.appId); this.authenticate() .then(() => this.connect()) .then(() => this.addIncomingListener()) .catch(err => { console.log(err) }); } connect() { return new Promise((resolve, reject) => { SendBirdCall.connectWebSocket() .then(() => { resolve("Connected"); }) .catch(() => { reject("Websocket Failed"); }); }); } authenticate() { return new Promise((resolve, reject) => { SendBirdCall.authenticate({ userId: this.state.userId, accessToken: undefined }, (result, error) => { !!error ? reject(error) : resolve(result); }); }); } acceptCall() { let callOption = this.getCallOptions(); this.state.call.accept({ callOption } ); } getCallOptions() { let callOption = { remoteMediaView: document.getElementById('remote_element_id'), audioEnabled: true, videoEnabled: false } if (this.isVideoCall()) { callOption.localMediaView = document.getElementById('local_video_element_id'); callOption.videoEnabled = true; callOption.audioEnabled = true; } return callOption; } isVideoCall() { if (this.state.initAction === 'initVideo') { return true; } return false; } endCall() { this.state.call.end(); this.clearState(); } clearState(){ this.setState({isMuted: false}); this.setState({videoHidden: false}); } muteCall(){ this.setState({isMuted: !this.state.isMuted},() => { if(this.state.isMuted) { this.state.call.muteMicrophone(); } else { this.state.call.unmuteMicrophone(); } }); } toggleVideo(){ this.setState({videoHidden: !this.state.videoHidden},() => { if(this.state.videoHidden) { this.state.call.stopVideo(); } else { this.state.call.startVideo(); } }); } makeCall() { let callOption = this.getCallOptions(); const dialParams = { userId: this.state.targetUserId, isVideoCall: this.isVideoCall(), callOption }; try { const call = SendBirdCall.dial(dialParams, (call, error) => { if (error) { console.log(error); this.setState({errorMsg: error.toString()}) } else { this.setState({errorMsg: ''}) this.addDialOutListener(call); } }); } catch (e) { this.setState({errorMsg: e.message}) } } addDialOutListener(call) { call.onEstablished = (call) => this.setState({call, ...callStates.established}); call.onConnected = (call) => this.setState(callStates.connected); call.onEnded = (call) => { let _this = this; this.setState(callStates.ended); setTimeout(() => _this.setState({info: "Waiting..."}), 1000); }; } addIncomingListener() { console.log("Initizalized & ready..."); SendBirdCall.addListener(this.state.listenerId, { onRinging: (call) => { console.log(call); this.setState({call, ...callStates.ringing}); call.onEstablished = (call) => this.setState(callStates.established); call.onConnected = (call) => this.setState(callStates.connected); call.onEnded = (call) => this.setState(callStates.ended); } }); } componentWillUnmount() { this.clearState(); SendBirdCall.removeListener(this.state.listenerId); SendBirdCall.deauthenticate(); } videoWorkaround = (id) => { let mutedParam = ''; return ( <div dangerouslySetInnerHTML={{ __html: ` <video ${mutedParam} autoplay playsinline id="${id}" />` }} /> ); }; /** * for debugging */ callUserId = () => { return ( <div className="mb1 mt1"> <button onClick = {() => this.makeCall()}>Call User ID:</button> <input value={this.state.targetUserId} onChange={e => this.setState({ targetUserId: e.target.value })} id="targetUserId" type="text" placeholder="Target UserID" /> </div> ); } componentDidUpdate = () =>{ // console.log(this.state); } render() { let button; let button2; let button3; if (this.state.displayPickup) { button = ( <MIconButton className="btn--pickUp u-m2" aria-label="Pick Up!" onClick={() => this.acceptCall()} > <CallIcon /> </MIconButton> ) } if (this.state.displayEnd) { button = ( <MIconButton className="btn--hangUp u-m2" aria-label="Hang Up!" onClick={() => this.endCall()} > <CallEndIcon /> </MIconButton> ) } if (this.state.displayEnd) { button2 = ( <MIconButton className="btn--control u-m2" aria-label="Control" onClick={() => this.muteCall()} > {this.state.isMuted ? <VolumeOffIcon/> : <VolumeUpIcon/>} </MIconButton> ) } if (this.state.displayEnd && this.isVideoCall()) { button3 = ( <MIconButton className="btn--dark u-m2" aria-label="Control" onClick={() => this.toggleVideo()} > {this.state.videoHidden ? <VideocamOffIcon/> : <VideocamIcon/>} </MIconButton> ) } let mediaView = (<audio id = "remote_element_id" controls autoPlay/>); if(this.isVideoCall()){ mediaView = ( <div className="videosContainer"> <div className="tinyVideo"> {this.videoWorkaround("local_video_element_id")} </div> {this.videoWorkaround("remote_element_id")} </div> ); } let button8_debug; if (this.state.displayCall) { button8_debug = this.callUserId(); } return ( <div className="videoLayer"> {mediaView} <div className="videoInfoMsg"> { this.state.info } </div> { button8_debug } { button } { button2 } { button3 } { this.state.errorMsg ? <p className="sbError">{this.state.errorMsg}</p> : '' } </div> ); } } export default SBCall;
The definition of states that are used to control button visibility and displaying the status message:
// src/Calls/settings.js export const callStates = { ringing: { info: "Ringing... Pick up!", displayPickup: true, displayCall: false, displayCallEnd: false }, established : { info: "Call established", displayPickup: false, displayCall: false, displayCallEnd: true }, connected: { info: "Call connected", displayPickup: false, displayCall: false, displayEnd: true }, ended: { info: "Call ended", displayPickup: false, displayEnd: false, displayCall: true } }
Styles for video container, the pick-up and hang up icon:
/** src/Calls/index.css */ .videosContainer{ background:#333; position:relative; } .videosContainer:before { display: block; content: ""; padding-top: 56.25%; } .videosContainer #remote_element_id { position: absolute; top: 0; left: 0; bottom: 0; right: 0; width: 100%; height: 100%; } .tinyVideo { position: absolute; width: 100px; top: 10px; left: 10px; background: #000; border:1px solid #333; z-index:1; overflow:hidden; } .tinyVideo:before { display: block; content: ""; padding-top: 56.25%; } #local_video_element_id { position: absolute; top: 0; left: 0; bottom: 0; right: 0; width: 100%; height: 100%; } .btn--pickUp { background:green !important; color:#fff !important; } .btn--hangUp { background:red !important; color:#fff !important; } .btn--control { background:dodgerblue !important; color:#fff !important; } .btn--dark { background:#333 !important; color:#fff !important; } .videoInfoMsg { background: #fff; padding: 10px; margin: 10px; border: 1px solid #ccc; } .sbError{ color:red; margin:2px; } .u-m2 { margin:1rem !important; }
Running demo
The demo needs to authenticate a particular sendbird user by checking ?user_id url GET param. For testing purposes, you can open 2 browser windows:
http://localhost:3001/?user_id=3_8e81bbd3-3aeb-4bf7-be5a-a3d91b73f739
http://localhost:3001/?user_id=3_b68af04f-72af-49ff-8cd7-f54b43091d3a
and initiate a call from window1 and a pickup in window2. The user has the ability to mute his microphone and disable the video from the camera. The red icon is used to hang up the conversation.
Voice Call in SendBird
To initiate a voice call, we’re using the same functions, but with videoEnabled: false. The participants of the call can mute their microphones or end a call using the red button.
SendBird tools
You can also use the SendBird tools available in the Main panel: Direct calls / 1-to-1 call / Make a Call widget. It has the ability to initiate a call to a particular user id and test if your application is working correctly.
Integrate Calls with Chat
Beside calls, SendBird offers Chat API and Chat SDKs. The React UIKit includes a predefined set of components that allow to build a custom chat with advanced analytics. ‘Calls integration to Chat’ enables the option to call anyone in a channel. More info is available in sendbird documentation: https://sendbird.com/docs/calls/v1/javascript/tutorials/calls-integration-to-chat
That’s it for today’s tutorial. Make sure to follow us for other tips and guidelines, and don’t forget to subscribe to our newsletter.