3. WebRTC tutorial series - Video Calling

In this lesson, we will take a look at implementing Video calling using WebRTC and Ably.
With the advent of WebRTC and the increasing capacity of browsers to handle peer-to-peer communications in real time, its easier now than ever to build realtime video calling apps.

Step 1 – Create your Ably app and API key

To follow this tutorial, you will need an Ably account. Sign up for a free account if you don’t already have one.

Access to the Ably global messaging platform requires an API key for authentication. API keys exist within the context of an Ably application and each application can have multiple API keys so that you can assign different capabilities and manage access to channels and queues.

You can either create a new application for this tutorial, or use an existing one.

To create a new application and generate an API key:

  1. Log in to your Ably account dashboard
  2. Click the “Create New App” button
  3. Give it a name and click “Create app”
  4. Copy your private API key and store it somewhere. You will need it for this tutorial.

To use an existing application and API key:

  1. Select an application from “Your apps” in the dashboard
  2. In the API keys tab, choose an API key to use for this tutorial. The default “Root” API key has full access to capabilities and channels.
  3. Copy the Root API key and store it somewhere. You will need it for this tutorial.

    Copy API Key screenshot

Step 2 – Choosing a WebRTC library

Dealing with WebRTC directly might be a bit tedious as it generally involves a lot of lines of code. However, there are many WebRTC libraries available that provide high-level abstractions and expose only a few methods to users, while handling major uplifting themselves. WebRTC has been an emerging standard and is still somewhat in flux, so it’s crucial to make sure that whichever library you choose is up to date and well maintained.

In all the chapters of this tutorial, we will be using simple-peer – a simple WebRTC library for video/voice and data channels.

Step 3 – Designing a simple HTML layout

Create a new file called index.html and add the following code:

<!DOCTYPE html>
<html>
<head>
    <title>Ably WebRTC Video call Demo</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/6.2.1/adapter.min.js"></script>
    <script src="https://cdn.ably.com/lib/ably.min-1.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/simple-peer/9.1.2/simplepeer.min.js"></script>
    <script src="ably-videocall.js"></script>
    <script src="connection-helper.js"></script>
    <div class="container-fluid" style="margin-top: 5em;">
        <div class="container" id="join">
            <h4 id="online">Users online (0) </h4>
            <ul id="memberList"></ul>
        </div>
        <div class="container" id="call" style="display:none;">
            <video width="320" height="240" id="local" controls></video>
            <video width="320" height="240" id="remote" controls></video>
            <button class="btn btn-xs btn-danger" onclick="handleEndCall()">End call</button>
        </div>
    </div>
</body>
<style>
    small {
        border-bottom: 2px solid black;
    }

    li {
        list-style: none;
    }
</style>
</html>

See this step in Github

What is going on in the code block above?
The code snippet above is a basic HTML declaration in which we:

  1. Referenced the Bootstrap CSS library
  2. Referenced Adapter.js to iron out cross platform issues for WebRTC
  3. Referenced the Ably JavaScript library
  4. Referenced the simple peer JavaScript library.
  5. Referenced a JavaScript file named ably-videocall.js (which we will create soon).
  6. Referenced a JavaScript file named connection-helper.js (which we will create soon).
  7. Declared a div with the id of join which will hold the list of users online.
  8. Declared a div with the id of call which holds the video feeds.

Step 4 – Defining the connection helper class

In step 3 above, we Referenced a library called connection-helper.js. This library helps to manage the simple-peer connections, so as to keep our code organized, as we will be having multiple instances of the connection.

Create a new file called connection-helper.js and add the following code:

class Connection {
    constructor(remoteClient, AblyRealtime, initiator, stream) {
        console.log(`Opening connection to ${remoteClient}`)
        this._remoteClient = remoteClient
        this.isConnected = false
        this._p2pConnection = new SimplePeer({
            initiator: initiator,
            stream: stream
        })
        this._p2pConnection.on('signal', this._onSignal.bind(this))
        this._p2pConnection.on('error', this._onError.bind(this))
        this._p2pConnection.on('connect', this._onConnect.bind(this))
        this._p2pConnection.on('close', this._onClose.bind(this))
        this._p2pConnection.on('stream', this._onStream.bind(this))
    }
    handleSignal(signal) {
        this._p2pConnection.signal(signal)
    }
    send(msg) {
        this._p2pConnection.send(msg)
    }
    destroy() {
        this._p2pConnection.destroy()
    }
    _onSignal(signal) {
        AblyRealtime.publish(`rtc-signal/${this._remoteClient}`, {
            user: clientId,
            signal: signal
        })
    }
    _onConnect() {
        this.isConnected = true
        console.log('connected to ' + this._remoteClient)
    }
    _onClose() {
        console.log(`connection to ${this._remoteClient} closed`)
        handleEndCall(this._remoteClient)
    }
    _onStream(data) {
        receiveStream(this._remoteClient, data)
    }
    _onError(error) {
        console.log(`an error occurred ${error.toString()}`)
    }
}

See this step in Github

What is going on in the code block above?

In the code block above, we have defined a connection class which makes use of the simple-peer library to manage connections.

In our constructor, we accept 4 parameters:

  1. remoteClient: this refers to the other party we want to connect to.
  2. AblyRealtime: this refers to an instance of an Ably channel
  3. initiator: this is a Boolean parameter that states if this is the peer initiating the connection or not.
  4. stream: this is the video/audio stream coming directly from the user’s webcam and microphone.

Let us move ahead and understand the functions defined in the class:

  1. handleSignal: this function is called when a signal has been sent via the realtime channel. This function passes the signal to the current peer connection.
  2. send: this method is used to send messages to the other peer. The function, in turn, calls the send method of the simple-peer instance.
  3. destroy: this method is used to destroy the connection to the other peer completely. The function, in turn, calls the destroy method of the simple-peer instance.
  4. _onSignal: this method is called by the simple-peer library when it wants to send a signal to the other peer. In this function, we make use of the Ably realtime channel to publish the signal to the other peer.
  5. _onConnect: this method sets the class property isConnected to true. This is an indicator that the peers have been connected.
  6. _onClose: this method deletes the current connection instance from an object called connections, which we will define later on in the ably-videocall.js file.
  7. _onStream: this method is called when the video/audio stream from the other peer has been received.
  8. _onError: should an error occur in our connection, this method will be called. Currently, we just log out the details of the error.

Step 5 – Displaying online users

Before we can make calls via WebRTC, we need to verify that the other peer we want to connect to is online, as WebRTC would not connect with offline peers.

Create a file named ably-videocall.js and add the following:

var membersList = []
var connections = {}
var currentCall
var localStream
var constraints = { video: true, audio: true }
var apiKey = 'XXX_API_KEY'
var clientId = 'client-' + Math.random().toString(36).substr(2, 16)
var realtime = new Ably.Realtime({ key: apiKey, clientId: clientId })
var AblyRealtime = realtime.channels.get('ChatChannel')

AblyRealtime.presence.subscribe('enter', function(member) {
    AblyRealtime.presence.get((err, members) => {
        membersList = members
        renderMembers()
    })
})
AblyRealtime.presence.subscribe('leave', member => {
    AblyRealtime.presence.get((err, members) => {
        membersList = members
        renderMembers()
    })
})
AblyRealtime.presence.enter()

function renderMembers() {
    var list = document.getElementById('memberList')
    var online = document.getElementById('online')
    online.innerHTML = 'Users online (' + (membersList.length === 0 ? 0 : membersList.length - 1) + ')'
    var html = ''
    if (membersList.length === 1) {
        html += '<li> No member online </li>'
        list.innerHTML = html
        return
    }
    for (var index = 0; index < membersList.length; index++) {
        var element = membersList[index]
        if (element.clientId !== clientId) {
            html += '<li><small>' + element.clientId + ' <button class="btn btn-xs btn-success" onclick=call("' + element.clientId + '")>call now</button> </small></li>'
        }
    }
    list.innerHTML = html
}

See this step in Github

What is going on in the code block above?

Before we move forward, please ensure that you have set the value of apikey to your private Ably API key. The other variables are explained below:

  1. membersList: this is an array of all currently online members you can chat with.
  2. connections: this is an object which will contain keys of each client you have a video call with, with their current connection object.
  3. currentCall: this variable holds the clientId of the user you are currently in a call with.
  4. localStream: this variable will hold a reference to the stream coming from your local webcam/microphone.
  5. constraints: an object which defines the media objects needed for the computer to generate the stream.
  6. apiKey: this is your API key for Ably as generated in step 1
  7. clientId: this is a unique identification of the current person who wants to connect to both Ably and WebRTC. In your application, you might need to get this key from a database or using some other authorised methods.
  8. realtime: an instance of Ably
  9. AblyRealtime: an instance of an Ably channel.

First, we need to make an enter subscription, so we will be notified once a new member has joined our channel, so we make the subscription to AblyRealtime.presence.subscribe. In the callback, we get a list of all current members and add it to our member’s list. After this, we call on the render members function.

Also, as we made a subscription to enter events, we make a similar subscription to leave events, so we can keep track of members who have left the channel.

We need to call the AblyRealtime.presence.enter() method so Ably is aware that we want to enter into the presence channels and keep track of the new client.

Lastly, we have our renderMembers functions, which loops through the membersList, and appends them as list items to the ul tag with the id of memberList.

Step 6 – Making and video receiving calls

We have been able to identify who is online and ready to receive instant messages using WebRTC data channels, now we move to the part where we send and receive calls.

Before jumping into the code, it would be nice to notify users when a call is coming in, and when a call is declined. Let’s go ahead and implement that.

function call(client_id) {
    if (client_id === clientId) return
    alert(`attempting to call ${client_id}`)
    AblyRealtime.publish(`incoming-call/${client_id}`, {
            user: clientId
        })
}
AblyRealtime.subscribe(`incoming-call/${clientId}`, call => {
    if (currentCall != undefined) {
        // user is on another call
        AblyRealtime.publish(`call-details/${call.data.user}`, {
            user: clientId,
            msg: 'User is on another call'
        })
        return
    }
    var isAccepted = confirm(`You have a call from ${call.data.user}, do you want to accept?`)
    if (!isAccepted) {
        // user rejected the call
        AblyRealtime.publish(`call-details/${call.data.user}`, {
            user: clientId,
            msg: 'User declined the call'
        })
        return
    }
    currentCall = call.data.user
    AblyRealtime.publish(`call-details/${call.data.user}`, {
        user: clientId,
        accepted: true
    })
})
AblyRealtime.subscribe(`call-details/${clientId}`, call => {
    if (call.data.accepted) {
        initiateCall(call.data.user)
    } else {
        alert(call.data.msg)
    }
})

See this step in Github

What is going on in the code block above?

To chat with a user/member, we need to click the call button next to their name. This button, in turn, calls the call method, which we have just defined.

First, we send an incoming-call event coined by joining the incoming-call string with the client’s id, to notify the other peer that someone is requesting a video call.

Next, we subscribe to the incoming-call event, then do a couple of checks.

  1. check if user’s currentCall is not undefined, which means he is on another call, then tell the caller the other peer is on a call.
  2. verify that the user wants to pick the call. if he does not, tell the caller so, else, tell the user the call request has been accepted.

After the checks are done, we subscribe to the call-details event to receive information on the requested call. Once the call is accepted, fire a method called initiateCall.

Now, let’s go ahead and implement the call, receive and end call features.

function initiateCall(client_id) {
    navigator.mediaDevices.getUserMedia(constraints)
        .then(function(stream) {
            /* use the stream */
            localStream = stream
            var video = document.getElementById('local')
            video.src = window.URL.createObjectURL(stream)
            video.play()
                // Create a new connection
            currentCall = client_id
            if (!connections[client_id]) {
                connections[client_id] = new Connection(client_id, AblyRealtime, true, stream)
            }
            document.getElementById('call').style.display = 'block'
        })
        .catch(function(err) {
            /* handle the error */
            alert('Could not get video stream from source')
        })
}
AblyRealtime.subscribe(`rtc-signal/${clientId}`, msg => {
    if (localStream === undefined) {
        navigator.mediaDevices.getUserMedia(constraints)
            .then(function(stream) {
                /* use the stream */
                localStream = stream
                var video = document.getElementById('local')
                video.src = window.URL.createObjectURL(stream)
                video.play()
                connect(msg.data, stream)
            })
            .catch(function(err) {
                alert('error occurred while trying to get stream')
            })
    } else {
        connect(msg.data, localStream)
    }
})
function connect(data, stream) {
    if (!connections[data.user]) {
        connections[data.user] = new Connection(data.user, AblyRealtime, false, stream)
    }
    connections[data.user].handleSignal(data.signal)
    document.getElementById('call').style.display = 'block'
}
function receiveStream(client_id, stream) {
    var video = document.getElementById('remote')
    video.src = window.URL.createObjectURL(stream)
    video.play()
    renderMembers()
}

See this step in Github

Here, we defined the initiateCall function, which gets the stream from the user via navigator.mediaDevices.getUserMedia, then passes it to the Connection class, to initiate a connection to the other peer.

If you remember, in step 4, when we defined the Connection class, we handled the _onSignal event using Ably. Here, we defined the rtc-signal/${clientId} event for that purpose.

Since we are attempting to connect to the other peer, this event would be fired. So in this event, we check if a stream is assigned, if not, get the user stream, and pass it to the connect function. If assigned, call the connect function

In the connect function, we check if the connection already exists. If it doesn’t, create a new connection instance with the stream passed into it, else if it does exist, send the signal to the connection.

Next, you would see the receiveStream function. This function was called in step 4, under the _onStream. What it does, is to load the remote stream into the second video tag in our markup.

That’s it, we are connected to the other peer, and we can see a live video.

How do we end calls then?

Let’s take a look at the function below:

function handleEndCall(client_id = null) {
    if (client_id && client_id != currentCall) {
        return
    }
    client_id = currentCall;
    alert('call ended')
    currentCall = undefined
    connections[client_id].destroy()
    delete connections[client_id]
    for (var track of localStream.getTracks()) {
        track.stop()
    }
    localStream = undefined
    document.getElementById('call').style.display = 'none'
}

See this step in Github

Note that in the function above, we receive an optional argument of client_id. Why is this argument optional?

If you remember, in the Connection class, the _onClose() method calls this function, passing in the client id of the other peer whose connection was closed/dropped. We use this parameter, to know which connection to delete from our connections object. This function is also called when you click the end call button, but here, no parameter is passed, as we have a variable called currentCall, which holds the value of the call you must be ending.

In this function, we destroy the peer connection, as well as delete the key from our connection objects. We go ahead to stop the Video and Audio streams coming from the webcam, then hide the video inputs.

Step 7 – Testing our app

To test the messaging system we have just built, serve your index.html file as a static file via any server of your choice.

Using Node.js, install the http-server package:

npm install http-server -g

Once the http-server package is done installing, at the root of your working directory, run:

http-server

Navigate to http://127.0.0.1:8081 to view the demo.

Live demo

Note: This demo uses the getUserMedia API as illustrated throughout the tutorial. At the time of the release of this tutorial, this API has limited support on mobile browsers running on iOS. You can check the current support here.

To try this example yourself, Open this demo in a new browser window to see WebRTC video calls in action.

Please note that if you are using a single Pc to test the demo, please use the same browser to open a new window, as your video and audio source cannot be captured by two different browsers at once.

Conclusion

In this tutorial, we have seen how to use WebRTC in conjunction with Ably to create real time video call app.

Next: Chapter 3 – WebRTC file transfers with Ably