In this tutorial, we’ll explore WebRTC, a popular and reliable framework for real-time communication (RTC). We’ll focus on how to use it with Flutter WebRTC. If you’re wondering how to build real-time apps easily, you’re in the right place. Let’s get started!
What is WebRTC?
WebRTC stands for Web Real-time communication. It is open-source and free for real time communication(audio, video and generic data). The technology is available on all modern browsers as well as on native platforms like Android, iOS, MacOS, Linux, Windows, etc. To understand WebRTC, we need to know three key technologies:
Signalling: WebRTC uses signalling to exchange connection details between peers, typically through WebSocket, allowing communication through firewalls and NATs.
SDP (Session Description Protocol): SDP describes session info like codecs, IP addresses, and ports. Peers exchange SDP offers and answers to negotiate connections.
ICE (Interactive Connectivity Establishment): ICE helps peers connect by handling issues like firewalls, NATs, and IP addresses, using STUN or TURN servers for connectivity.
What is Flutter WebRTC?
Flutter WebRTC is a plugin for Flutter that brings real-time communication (RTC) features to web and mobile apps. It uses protocols and APIs to allow direct communication between browsers and mobile apps without needing extra plugins or software.
Setting up Server Side Code:
First of all, we need to setup signalling server.
Step 1: Create a new directory for your project:
mkdir webrtc-signaling-server
cd webrtc-signaling-server
Step 2: Initialize a new Node.js project:
npm init
Step 3: Install the required dependencies:
npm install socket.io
Step 4: Create a new file named index.js
and add the following code:
let port = process.env.PORT || 5000;
let IO = require("socket.io")(port, {
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
IO.use((socket, next) => {
if (socket.handshake.query) {
let callerId = socket.handshake.query.callerId;
socket.user = callerId;
next();
}
});
IO.on("connection", (socket) => {
console.log(socket.user, "Connected");
socket.join(socket.user);
socket.on("makeCall", (data) => {
let calleeId = data.calleeId;
let sdpOffer = data.sdpOffer;
socket.to(calleeId).emit("newCall", {
callerId: socket.user,
sdpOffer: sdpOffer,
});
});
socket.on("answerCall", (data) => {
let callerId = data.callerId;
let sdpAnswer = data.sdpAnswer;
socket.to(callerId).emit("callAnswered", {
callee: socket.user,
sdpAnswer: sdpAnswer,
});
});
socket.on("IceCandidate", (data) => {
let calleeId = data.calleeId;
let iceCandidate = data.iceCandidate;
socket.to(calleeId).emit("IceCandidate", {
sender: socket.user,
iceCandidate: iceCandidate,
});
});
});
Step 5: Start the server by running:
node index.js
Step 6: Using ngrok to Expose Your Local Server:
We need to use ngrok to create a tunnel to our localhost port and expose it to the internet. Setup ngrok and then, in a new terminal window run the following command
$ ngrok http 5000
You should get an output with a forwarding address like this. Copy the URL onto the clipboard. Make sure you record the https
url. We will use the URL in flutter side.
Forwarding https://xxxxxxxx.ngrok.io -> http://localhost:5000
Setup Flutter WebRTC Project:
Step 1: Create Flutter WebRTC app Project
flutter create flutter_webrtc_app
Step 2: Add project dependency for Flutter WebRTC app
flutter pub add flutter_webrtc socket_io_client
Step 3: Flutter WebRTC Setup for IOS and Android
- Flutter WebRTC iOS Setup
Add the following lines to your Info.plist file, located at <project root>/ios/Runner/Info.plist.
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) Camera Usage!</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) Microphone Usage!</string>
These lines allows your app to access camera and microphone.
Note: Refer, if you have trouble with iOS setup.
- Flutter WebRTC Android Setup
Add following lines in AndroidManifest.xml, located at <project root>/android/app/src/main/AndroidManifest.xml
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
If necessary, you will need to increase minSdkVersion
of defaultConfig
up to 23
in app level build.gradle file.
Step 4: Create SingallingService for Flutter WebRTC app
The Signalling Service will deal with the communication to the Signalling Server. Here, we will use socket.io client to connect with socker.io server, which is basically a WebSocket Server.
import 'dart:developer';
import 'package:socket_io_client/socket_io_client.dart';
class SignallingService {
// instance of Socket
Socket? socket;
SignallingService._();
static final instance = SignallingService._();
init({required String websocketUrl, required String selfCallerID}) {
// init Socket
socket = io(websocketUrl, {
"transports": ['websocket'],
"query": {"callerId": selfCallerID}
});
// listen onConnect event
socket!.onConnect((data) {
log("Socket connected !!");
});
// listen onConnectError event
socket!.onConnectError((data) {
log("Connect Error $data");
});
// connect socket
socket!.connect();
}
}
Step 5: Create JoinScreen for Flutter WebRTC app
JoinScreen will be a StatefulWidget, which allows the user to join a session. Using this screen, the user can start a session or join a session when some other user calls this user using CallerID.
import 'package:flutter/material.dart';
import 'call_screen.dart';
import '../services/signalling.service.dart';
class JoinScreen extends StatefulWidget {
final String selfCallerId;
const JoinScreen({super.key, required this.selfCallerId});
@override
State<JoinScreen> createState() => _JoinScreenState();
}
class _JoinScreenState extends State<JoinScreen> {
dynamic incomingSDPOffer;
final remoteCallerIdTextEditingController = TextEditingController();
@override
void initState() {
super.initState();
// listen for incoming video call
SignallingService.instance.socket!.on("newCall", (data) {
if (mounted) {
// set SDP Offer of incoming call
setState(() => incomingSDPOffer = data);
}
});
}
// join Call
_joinCall({
required String callerId,
required String calleeId,
dynamic offer,
}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CallScreen(
callerId: callerId,
calleeId: calleeId,
offer: offer,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
appBar: AppBar(
centerTitle: true,
title: const Text("P2P Call App"),
),
body: SafeArea(
child: Stack(
children: [
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: TextEditingController(
text: widget.selfCallerId,
),
readOnly: true,
textAlign: TextAlign.center,
enableInteractiveSelection: false,
decoration: InputDecoration(
labelText: "Your Caller ID",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10.0),
),
),
),
const SizedBox(height: 12),
TextField(
controller: remoteCallerIdTextEditingController,
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: "Remote Caller ID",
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10.0),
),
),
),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
side: const BorderSide(color: Colors.white30),
),
child: const Text(
"Invite",
style: TextStyle(
fontSize: 18,
color: Colors.white,
),
),
onPressed: () {
_joinCall(
callerId: widget.selfCallerId,
calleeId: remoteCallerIdTextEditingController.text,
);
},
),
],
),
),
),
if (incomingSDPOffer != null)
Positioned(
child: ListTile(
title: Text(
"Incoming Call from ${incomingSDPOffer["callerId"]}",
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.call_end),
color: Colors.redAccent,
onPressed: () {
setState(() => incomingSDPOffer = null);
},
),
IconButton(
icon: const Icon(Icons.call),
color: Colors.greenAccent,
onPressed: () {
_joinCall(
callerId: incomingSDPOffer["callerId"]!,
calleeId: widget.selfCallerId,
offer: incomingSDPOffer["sdpOffer"],
);
},
)
],
),
),
),
],
),
),
);
}
}
Step 6: Create CallScreen for Flutter WebRTC app
- CallScreen shows both the local and remote video streams.
- Controls include toggleCamera, toggleMic, switchCamera, and endCall.
- An RTCPeerConnection is set up between peers.
- SDP Offer and SDP Answer are created to initiate the connection.
- ICE Candidate data is shared via a signalling server (using socket.io).
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../services/signalling.service.dart';
class CallScreen extends StatefulWidget {
final String callerId, calleeId;
final dynamic offer;
const CallScreen({
super.key,
this.offer,
required this.callerId,
required this.calleeId,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
// socket instance
final socket = SignallingService.instance.socket;
// videoRenderer for localPeer
final _localRTCVideoRenderer = RTCVideoRenderer();
// videoRenderer for remotePeer
final _remoteRTCVideoRenderer = RTCVideoRenderer();
// mediaStream for localPeer
MediaStream? _localStream;
// RTC peer connection
RTCPeerConnection? _rtcPeerConnection;
// list of rtcCandidates to be sent over signalling
List<RTCIceCandidate> rtcIceCadidates = [];
// media status
bool isAudioOn = true, isVideoOn = true, isFrontCameraSelected = true;
@override
void initState() {
// initializing renderers
_localRTCVideoRenderer.initialize();
_remoteRTCVideoRenderer.initialize();
// setup Peer Connection
_setupPeerConnection();
super.initState();
}
@override
void setState(fn) {
if (mounted) {
super.setState(fn);
}
}
_setupPeerConnection() async {
// create peer connection
_rtcPeerConnection = await createPeerConnection({
'iceServers': [
{
'urls': [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302'
]
}
]
});
// listen for remotePeer mediaTrack event
_rtcPeerConnection!.onTrack = (event) {
_remoteRTCVideoRenderer.srcObject = event.streams[0];
setState(() {});
};
// get localStream
_localStream = await navigator.mediaDevices.getUserMedia({
'audio': isAudioOn,
'video': isVideoOn
? {'facingMode': isFrontCameraSelected ? 'user' : 'environment'}
: false,
});
// add mediaTrack to peerConnection
_localStream!.getTracks().forEach((track) {
_rtcPeerConnection!.addTrack(track, _localStream!);
});
// set source for local video renderer
_localRTCVideoRenderer.srcObject = _localStream;
setState(() {});
// for Incoming call
if (widget.offer != null) {
// listen for Remote IceCandidate
socket!.on("IceCandidate", (data) {
String candidate = data["iceCandidate"]["candidate"];
String sdpMid = data["iceCandidate"]["id"];
int sdpMLineIndex = data["iceCandidate"]["label"];
// add iceCandidate
_rtcPeerConnection!.addCandidate(RTCIceCandidate(
candidate,
sdpMid,
sdpMLineIndex,
));
});
// set SDP offer as remoteDescription for peerConnection
await _rtcPeerConnection!.setRemoteDescription(
RTCSessionDescription(widget.offer["sdp"], widget.offer["type"]),
);
// create SDP answer
RTCSessionDescription answer = await _rtcPeerConnection!.createAnswer();
// set SDP answer as localDescription for peerConnection
_rtcPeerConnection!.setLocalDescription(answer);
// send SDP answer to remote peer over signalling
socket!.emit("answerCall", {
"callerId": widget.callerId,
"sdpAnswer": answer.toMap(),
});
}
// for Outgoing Call
else {
// listen for local iceCandidate and add it to the list of IceCandidate
_rtcPeerConnection!.onIceCandidate =
(RTCIceCandidate candidate) => rtcIceCadidates.add(candidate);
// when call is accepted by remote peer
socket!.on("callAnswered", (data) async {
// set SDP answer as remoteDescription for peerConnection
await _rtcPeerConnection!.setRemoteDescription(
RTCSessionDescription(
data["sdpAnswer"]["sdp"],
data["sdpAnswer"]["type"],
),
);
// send iceCandidate generated to remote peer over signalling
for (RTCIceCandidate candidate in rtcIceCadidates) {
socket!.emit("IceCandidate", {
"calleeId": widget.calleeId,
"iceCandidate": {
"id": candidate.sdpMid,
"label": candidate.sdpMLineIndex,
"candidate": candidate.candidate
}
});
}
});
// create SDP Offer
RTCSessionDescription offer = await _rtcPeerConnection!.createOffer();
// set SDP offer as localDescription for peerConnection
await _rtcPeerConnection!.setLocalDescription(offer);
// make a call to remote peer over signalling
socket!.emit('makeCall', {
"calleeId": widget.calleeId,
"sdpOffer": offer.toMap(),
});
}
}
_leaveCall() {
Navigator.pop(context);
}
_toggleMic() {
// change status
isAudioOn = !isAudioOn;
// enable or disable audio track
_localStream?.getAudioTracks().forEach((track) {
track.enabled = isAudioOn;
});
setState(() {});
}
_toggleCamera() {
// change status
isVideoOn = !isVideoOn;
// enable or disable video track
_localStream?.getVideoTracks().forEach((track) {
track.enabled = isVideoOn;
});
setState(() {});
}
_switchCamera() {
// change status
isFrontCameraSelected = !isFrontCameraSelected;
// switch camera
_localStream?.getVideoTracks().forEach((track) {
// ignore: deprecated_member_use
track.switchCamera();
});
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
appBar: AppBar(
title: const Text("P2P Call App"),
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Stack(children: [
RTCVideoView(
_remoteRTCVideoRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
Positioned(
right: 20,
bottom: 20,
child: SizedBox(
height: 150,
width: 120,
child: RTCVideoView(
_localRTCVideoRenderer,
mirror: isFrontCameraSelected,
objectFit:
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
)
]),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
icon: Icon(isAudioOn ? Icons.mic : Icons.mic_off),
onPressed: _toggleMic,
),
IconButton(
icon: const Icon(Icons.call_end),
iconSize: 30,
onPressed: _leaveCall,
),
IconButton(
icon: const Icon(Icons.cameraswitch),
onPressed: _switchCamera,
),
IconButton(
icon: Icon(isVideoOn ? Icons.videocam : Icons.videocam_off),
onPressed: _toggleCamera,
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_localRTCVideoRenderer.dispose();
_remoteRTCVideoRenderer.dispose();
_localStream?.dispose();
_rtcPeerConnection?.dispose();
super.dispose();
}
}
Step 7: Main.dart file
We will pass websocketUrl (signalling server URL) to JoinScreen and create random callerId for user.
import 'dart:math';
import 'package:flutter/material.dart';
import 'screens/join_screen.dart';
import 'services/signalling.service.dart';
void main() {
// start videoCall app
runApp(VideoCallApp());
}
class VideoCallApp extends StatelessWidget {
VideoCallApp({super.key});
// signalling server url
final String websocketUrl = "WEB_SOCKET_SERVER_URL"; //ngrok url that we have created in step 6 of server side
// generate callerID of local user
final String selfCallerID =
Random().nextInt(999999).toString().padLeft(6, '0');
@override
Widget build(BuildContext context) {
// init signalling service
SignallingService.instance.init(
websocketUrl: websocketUrl,
selfCallerID: selfCallerID,
);
// return material app
return MaterialApp(
darkTheme: ThemeData.dark().copyWith(
useMaterial3: true,
colorScheme: const ColorScheme.dark(),
),
themeMode: ThemeMode.dark,
home: JoinScreen(selfCallerId: selfCallerID),
);
}
}
Thank you for reading 👋
I hope you enjoyed this article. If you have any queries or suggestions please let me know in the comments down below.
I’m Shehzad Raheem 📱 Flutter Developer and I help firms to fulfill their Mobile Application Development, Android Development, and Flutter Development needs. If you want to discuss any project, drop me a message
Follow us: