Back to Blog

Connecting to Multiple Channels with Agora on React-Native

Connecting to Multiple Channels with Agora on React-Native

Since the release of v3.0.0 of Agora’s SDK for React-Native users can now join an unlimited number of channels at the same time. But you can publish your own camera feed to only one channel at a time.

This ability can be really handy in the case of multiple breakout rooms, where you can both send and receive video from a primary room while also receiving videos from secondary rooms.

We’ll be using the Agora RTC SDK for React Native for our example.

Before diving into how it works, let’s look at a few key points

  • We’ll use the SDK to connect to the first channel and join a video call normally. We’ll be streaming our video as well as receiving video from other users on the channel.
  • Next, we’ll join a second channel to receive video streams from all the users on that channel. Note that users on channel 2 will not be able to receive our video.
  • The two channels are separate: users on channel 1 and channel 2 don’t see each other. We can extend this functionality to join as many channels as required.

Structure of our example

This is the structure of the application:

.
├── android
├── components
│ └── Permission.ts
│ └── Style.ts
├── ios
├── App.tsx
.

Download the source

If you want to jump to the code and try it out for yourself, you can look at the readme for steps on how to run the app. The code is open source and available on GitHub. The app uses channel-1 and channel-2 as the channel names.

When you run the app, you’ll see two buttons: one to join and one to end the call. When you click start call, you should see your video in the top row, which contains videos from channel 1. The bottom row contains videos from channel 2.

Note: This guide does not implement token authentication which is recommended for all RTE apps running in production environments. For more information about token based authentication within the Agora platform please refer to this guide: https://docs.agora.io/en/Video/token?platform=All%20Platforms

How the App Works

App.tsx

App.tsx will be the entry point into the app. We’ll have all our code in this file:

import React, { Component } from 'react';
import { Platform, ScrollView, Text, TouchableOpacity, View } from 'react-native';
import RtcEngine, { RtcChannel, RtcLocalView, RtcRemoteView, VideoRenderMode } from 'react-native-agora';
import requestCameraAndAudioPermission from './components/Permission';
import styles from './components/Style';
interface Props {}
/**
* @property appId Used to
* @property token Used to join a channel
* @property channelNameOne Channel Name for the current session
* @property channelNameTwo Second Channel Name for the current session
* @property joinSucceed State variable for storing success
* @property peerIdsOne Array for storing connected peers on first channel
* @property peerIdsTwo Array for storing connected peers on second channel
*/
interface State {
appId: string;
token: string | null;
channelNameOne: string;
channelNameTwo: string;
joinSucceed: boolean;
peerIdsOne: number[];
peerIdsTwo: number[];
}
view raw App.tsx hosted with ❤ by GitHub

We start by writing the import statements. Next, we define an interface for our application state containing the following:

  • appId: Our Agora App ID
  • token: Token generated to join the channel
  • channelNameOne: Name for channel 1
  • channelNameTwo: Name for channel 2
  • joinSucceed: Boolean value to store if we’ve connected successfully
  • peerIdsOne: Array to store the UIDs of other users in channel 1
  • peerIdsTwo: Array to store the UIDs of other users in channel 2
...
export default class App extends Component<Props, State> {
_engine?: RtcEngine;
_channel?: RtcChannel;
constructor(props) {
super(props);
this.state = {
appId: 'ENTER YOUR APP ID',
token: null, //using token as null for App ID without certificate
channelNameOne: 'channel-1',
channelNameTwo: 'channel-2',
joinSucceed: false,
peerIdsOne: [],
peerIdsTwo: [],
};
if (Platform.OS === 'android') {
// Request required permissions from Android
requestCameraAndAudioPermission().then(() => {
console.log('requested!');
});
}
}
componentDidMount() {
this.init();
}
componentWillUnmount() {
this.destroy();
}
...
view raw App.tsx hosted with ❤ by GitHub

We define a class-based component: the _rtcEngine variable will store the instance of the RtcEngine class, and the _channel variable will store the instance of the RtcChannel class, which we can use to access the SDK functions.

In the constructor, we set our state variables and request permission for recording audio on Android. (We use a helper function from permission.ts, as described below.) When the component is mounted, we call the init function, which initializes the RTC engine and RTC channel. When the component unmounts, we destroy our engine and channel instances.

RTC initialization

...
/**
* @name init
* @description Function to initialize the Rtc Engine, attach event listeners and actions
*/
init = async () => {
const { appId, channelNameTwo } = this.state;
this._engine = await RtcEngine.create(appId);
this._channel = await RtcChannel.create(channelNameTwo);
await this._engine.enableVideo();
this._engine.addListener('Error', (err) => {
console.log('Error', err);
});
this._channel.addListener('Error', (err) => {
console.log('Error', err);
});
this._engine.addListener('UserJoined', (uid, elapsed) => {
console.log('UserJoined', uid, elapsed);
// Get current peer IDs
const { peerIdsOne } = this.state;
// If new user
if (peerIdsOne.indexOf(uid) === -1) {
this.setState({
// Add peer ID to state array
peerIdsOne: [...peerIdsOne, uid],
});
}
});
this._engine.addListener('UserOffline', (uid, reason) => {
console.log('UserOffline', uid, reason);
const { peerIdsOne } = this.state;
this.setState({
// Remove peer ID from state array one
peerIdsOne: peerIdsOne.filter((id) => id !== uid),
});
});
this._channel.addListener('UserJoined', (uid, elapsed) => {
console.log('UserJoined', uid, elapsed);
// Get current peer IDs
const { peerIdsTwo } = this.state;
// If new user
if (peerIdsTwo.indexOf(uid) === -1) {
this.setState({
// Add peer ID to state array
peerIdsTwo: [...peerIdsTwo, uid],
});
}
});
this._channel.addListener('UserOffline', (uid, reason) => {
console.log('UserOffline', uid, reason);
const { peerIdsTwo } = this.state;
this.setState({
// Remove peer ID from state array two
peerIdsTwo: peerIdsTwo.filter((id) => id !== uid),
});
});
// If Local user joins RTC channel
this._channel.addListener('JoinChannelSuccess', (channel, uid, elapsed) => {
console.log('JoinChannelSuccess', channel, uid, elapsed);
// Set state variable to true
this.setState({
joinSucceed: true,
});
});
};
...
view raw App.tsx hosted with ❤ by GitHub

We use the App ID to create our engine instance. The engine instance will be used to connect to channel 1, where we both send and receive the video. We also create our channel instance using the name of our second channel. The channel instance will be used only to receive videos from channel 2.

The RTC triggers a userJoined event for each user present when we join the channel and for each new user who joins after. The userOffline event is triggered when a user leaves the channel. We use event listeners on _engine and _channel to store and maintain our peerIdsOne and peerIdsTwo arrays containing the UIDs for users on both the channels.

We also attach a listener for joinChannelSuccess to update our state variable which is used to render our UI while we’re in the call.

Functions for our buttons

...
/**
* @name startCall
* @description Function to start the call
*/
startCall = async () => {
// channelOptions object used to auto subscribe to remote streams on second channel
let channelOptions = {
autoSubscribeAudio: true,
autoSubscribeVideo: true,
};
// Join Channel One using RtcEngine object, null token and channel name and UID as 0 to have the SDK auto generate it
await this._engine?.joinChannel(
this.state.token,
this.state.channelNameOne,
null,
0
);
// Join Channel Two using RtcChannel object, null token, uid as 0, channel name and channelOptions object
await this._channel?.joinChannel(this.state.token, null, 0, channelOptions);
};
/**
* @name endCall
* @description Function to end the call by leaving both channels
*/
endCall = async () => {
await this._engine?.leaveChannel();
await this._channel?.leaveChannel();
this.setState({ peerIdsOne: [], peerIdsTwo: [], joinSucceed: false });
};
/**
* @name destroy
* @description Function to destroy the RtcEngine and RtcChannel instances
*/
destroy = async () => {
await this._channel?.destroy();
await this._engine?.destroy();
};
...
view raw App.tsx hosted with ❤ by GitHub

The startCall function joins both the channels using the joinChannel method.

The endCall function leaves both the channels using the leaveChannel method and updates the state.

The destroy function destroys the instances of our engine and channel.

Rendering our UI

...
render() {
return (
<View style={styles.max}>
<View style={styles.max}>
<View style={styles.buttonHolder}>
<TouchableOpacity onPress={this.startCall} style={styles.button}>
<Text style={styles.buttonText}> Start Call </Text>
</TouchableOpacity>
<TouchableOpacity onPress={this.endCall} style={styles.button}>
<Text style={styles.buttonText}> End Call </Text>
</TouchableOpacity>
</View>
{this._renderVideos()}
</View>
</View>
);
}
_renderVideos = () => {
const { joinSucceed } = this.state;
return joinSucceed ? (
<View style={styles.fullView}>
{this._renderRemoteVideosOne()}
{this._renderRemoteVideosTwo()}
</View>
) : null;
};
_renderRemoteVideosOne = () => {
const { peerIdsOne } = this.state;
return (
<ScrollView
style={styles.scrollHolder}
contentContainerStyle={styles.scrollView}
horizontal={true}
>
<RtcLocalView.SurfaceView
style={styles.remote}
channelId={this.state.channelNameOne}
renderMode={VideoRenderMode.Hidden}
/>
{peerIdsOne.map((value) => {
return (
<RtcRemoteView.SurfaceView
style={styles.remote}
uid={value}
channelId={this.state.channelNameOne}
renderMode={VideoRenderMode.Hidden}
zOrderMediaOverlay={true}
key={value}
/>
);
})}
</ScrollView>
);
};
_renderRemoteVideosTwo = () => {
const { peerIdsTwo } = this.state;
return (
<ScrollView
style={styles.scrollHolder}
contentContainerStyle={styles.scrollView}
horizontal={true}
>
{peerIdsTwo.map((value) => {
return (
<RtcRemoteView.SurfaceView
style={styles.remote}
uid={value}
channelId={this.state.channelNameTwo}
renderMode={VideoRenderMode.Hidden}
zOrderMediaOverlay={true}
key={value}
/>
);
})}
</ScrollView>
);
};
}
view raw App.tsx hosted with ❤ by GitHub

We define the render function for displaying buttons to start and end the call and to display user videos from both channels.

We define a _renderVideos function to render the videos from both our channels using the — _renderRemoteVideosOne and _renderRemoteVideosTwo functions for channel 1 and channel 2. Each function contains scrollViews to hold videos from the channel. We use the UIDs stored in peerId arrays to render remote users’ videos by passing them to the RtcRemoteView.SurfaceView component.

Permission.ts

import {PermissionsAndroid} from 'react-native'
/**
* @name requestCameraAndAudioPermission
* @description Function to request permission for Audio and Camera
*/
export default async function requestCameraAndAudioPermission() {
try {
const granted = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.CAMERA,
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
])
if (
granted['android.permission.RECORD_AUDIO'] === PermissionsAndroid.RESULTS.GRANTED
&& granted['android.permission.CAMERA'] === PermissionsAndroid.RESULTS.GRANTED
) {
console.log('You can use the cameras & mic')
} else {
console.log('Permission denied')
}
} catch (err) {
console.warn(err)
}
}
view raw Permission.ts hosted with ❤ by GitHub

We’re exporting a helper function to request microphone permissions from the Android OS.

Style.ts

import { Dimensions, StyleSheet } from 'react-native';
const dimensions = {
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
};
export default StyleSheet.create({
max: {
flex: 1,
},
buttonHolder: {
height: 100,
marginVertical: 15,
alignItems: 'center',
flex: 1,
flexDirection: 'row',
justifyContent: 'space-evenly',
},
button: {
paddingHorizontal: 20,
paddingVertical: 10,
backgroundColor: '#0093E9',
borderRadius: 25,
},
buttonText: {
color: '#fff',
},
fullView: {
width: dimensions.width,
height: dimensions.height - 130,
},
remote: {
width: (dimensions.height - 150) / 2,
height: (dimensions.height - 150) / 2,
marginHorizontal: 2.5,
},
scrollView: {
paddingHorizontal: 2.5, justifyContent: 'center', alignItems: 'center'
},
scrollHolder: { flex: 1, borderWidth: 1 },
});
view raw Style.ts hosted with ❤ by GitHub

The Style.ts file contains the styling for the components.

Conclusion

That’s how we can build a video call app that can connect to two channels simultaneously. You can refer to the Agora React Native API Reference to see methods that can help you quickly add many features like muting the mic, setting audio profiles and audio mixing.

RTE Telehealth 2023
Join us for RTE Telehealth - a virtual webinar where we’ll explore how AI and AR/VR technologies are shaping the future of healthcare delivery.

Learn more about Agora's video and voice solutions

Ready to chat through your real-time video and voice needs? We're here to help! Current Twilio customers get up to 2 months FREE.

Complete the form, and one of our experts will be in touch.

Try Agora for Free

Sign up and start building! You don’t pay until you scale.
Try for Free