API Version: 6.5

Video Client

Downloads

Explained

This is an example of a video client. We will take you through the steps to create a functional video client using the video and chat subsystems. This will include accepting video request with and without a media stream, processing contacts and chat rooms.

One way video, receive only

/* Enable chat to allow feature (video negotiation) messages to be exchanged */
if ( IPCortex.PBX.enableChat() ) {
    console.log(TAG, 'Chat enable, enabling av feature');
    /* Register to receive new Av instances */
    IPCortex.PBX.enableFeature(
        'av',
        function(av) {
            /* Listen for updates to the Av instance */
            av.addListener('update', processFeed);
            processFeed(av);
        },
        ['chat']
    );
}
After a successful IPCortex.PBX.startFeed(), two steps are required to set up the video ('av') subsystem. The first is to enable the chat subsystem with IPCortex.PBX.enableChat() which returns a boolean indicating success/failure. The 'av' negotiation messages are exchanged via the chat subsystem, although these are transparent to the user.
The second step is to enable the 'av' subsystem with IPCortex.PBX.enableFeature('av', callback, ['chat']). The last parameter is an Array of transport mechanisms. Currently the only supported one is chat, but this option is here to allow future updates. The callback is called on every inbound (offered) 'av' request. Inside our callback we delegate initial processing and processing of subsequent updates to our 'av' call to the processFeed() function.
function processFeed(av) {
    /* Only process the Av instance if it has remote media */
    if ( typeof(av.remoteMedia) != 'object' )
        return;
    var videos = [];
    var feed = document.getElementById(av.id);
    for ( var id in av.remoteMedia ) {
        var video = document.getElementById(id);
        if ( av.remoteMedia[id].status == 'offered' ) {
            console.log(TAG, 'Accepting offer ' + av.remoteMedia[id].cN);
            /* Accept remote parties offer */
            av.accept();
        } else if ( av.remoteMedia[id].status == 'connected' && ! video ) {
            console.log(TAG, 'New remote media source ' + av.remoteMedia[id]);
            /* Create a new video tag to play/display the remote media */
            video = document.createElement('video');
            attachMediaStream(video, av.remoteMedia[id]);
            videos.push(video);
            video.id = id;
            video.play();
        } else if ( av.remoteMedia[id].status != 'connected' && video ) {
            /* Remove any video tags that are no longer in a 'connected' state */
            video.parentNode.removeChild(video);
        }
    }
    /* Create a feed container to hold video tags if it doesn't exist */
    if ( videos.length && ! feed ) {
        feed = document.createElement('div');
        document.body.appendChild(feed);
        feed.className = 'feed';
        feed.id = av.id;
    }
    /* Add the new video tags to the feed container */
    videos.forEach(
        function(video) {
            feed.appendChild(video);
        }
    );
    /* Remove the feed container if empty */
    if ( feed && feed.children.length < 1 )
        feed.parentNode.removeChild(feed);
}
The majority of processFeed() is <video> DOM management which we will not go through here but the thing to look at is the for loop over the av.remoteMedia array. A loop is required because an 'av' instance can have multiple streams.
For each stream, we are interested in three cases:
  • The stream is 'offered'. This means that the 'av' call is inbound and hasn't been answered yet. In this case we accept the call with av.accept(). The update listener will then fire again with the stream status as 'connected'.
  • The stream is 'connected' but there is no associated <video> in the DOM. This means that we haven't processed this stream yet - we have just accepted it. In this case we create a <video> and attach the stream to it.
  • The stream is no longer connected but its associated <video> still exists. This means that the stream has ended and that we need to cleanup - by removing its <video>.
In all other cases, we don't need to perform any specific action.
TEST - Load the page over HTTPS and login using incognito mode (Chrome) to avoid clashes. Start keevio, login as a different user, open a chat room with the first user and start video. You should see the keevio user's feed on the incognito browser window.

Two way video, offer only

/* API is ready, loop through the list of contacts */
IPCortex.PBX.contacts.forEach(
    function(contact) {
        /* Listen for updates in case the contact changes state */
        contact.addListener('update', processContact);
        processContact(contact);
    }
);
/* Enable chat to allow feature (video negotiation) messages to be exchanged */
var chatEnabled = IPCortex.PBX.enableChat(
    function(room) {
        /* Listen for updates to clean up dead rooms */
        room.addListener('update',
            function(room) {
                if ( rooms[room.roomID] && room.state == 'dead' )
                    delete rooms[room.roomID];
            }
        );
        /* If the room has come into existence due to a video request,
           start video with the stored stream */
        if ( room.cID == media.cID && media.stream ) {
            console.log(TAG, 'New room, starting video chat');
            /* Listen for updates on the Av instance */
            room.videoChat(media.stream).addListener('update', processFeed);
            media = {};
        }
        rooms[room.roomID] = room;
    }
);
if (chatEnabled) {
    console.log(TAG, 'Chat enable, enabling av feature');
    /* Register to receive new Av instances */
    IPCortex.PBX.enableFeature(
        'av',
        function(av) {
            /* Listen for updates to the Av instance */
            av.addListener('update', processFeed);
            processFeed(av);
        },
        ['chat']
    );
}
The first difference from the 'one-way' sample above is that we have to get a list of contacts to allow the user to initiate a call with someone. As usual, for cleanliness, we delegate initial and subsequent processing of contacts to the processContact() function. The 'update' listener is important because we need to listen for changes to online state, as you will see below.
Ignore the section after the room 'update' listener for now: we will come back to it.
var media = {};
var rooms = {};

function processContact(contact) {
    /* Don't process contacts that match the logged in user */
    if ( contact.cID == IPCortex.PBX.Auth.id )
        return;
    var element = document.getElementById(contact.cID);
    /* Return early if contact exists */
    if ( element ) {
        /* Remove offline contacts */
        if ( ! contact.canChat && element.parentNode )
            element.parentNode.removeChild(element);
        return;
    }
    /* Create online contact */
    if ( contact.canChat ) {
        var element = document.createElement('div');
        document.getElementById('contacts').appendChild(element);
        element.innerHTML = contact.name;
        element.className = 'contact';
        element.id = contact.cID;
        var offer = document.createElement('i');
        element.appendChild(offer);
        offer.className = 'material-icons contact-offer';
        offer.innerHTML = 'video_call';
        offer.addEventListener('click',
            function() {
                navigator.mediaDevices.getUserMedia({audio: true, video: true}).then(
                    /* Grab the user media */
                    function(stream) {
                        /* Check to see if a room already exists to start video on */
                        for ( var roomID in rooms ) {
                            if ( rooms[roomID].cID != contact.cID )
                                continue;
                            /* Listen for updates on the Av instance */
                            rooms[roomID].videoChat(stream).addListener('update', processFeed);
                            return;
                        }
                        /* No room, store stream and contact ID and open room */
                        media = {cID: contact.cID, stream: stream};
                        contact.chat();
                    }
                ).catch(
                    function() {
                        console.log(TAG, 'getUserMedia failed');
                    }
                );
            }
        );
    }
}
Again, there is a lot of DOM management here - maintaining the list of online contacts. The section to note is the 'click' listener on the 'offer' button. The user will click this to initiate a video call with that contact. In the handler, we first get the user's own media stream with the webRTC getUserMedia(). We then check whether a chatroom with the desired contact already exists or whether one needs to be created.
If a chatroom exists then all that remains to do is to start a video call in that room with room.videoChat(stream). Note that we pass it the user's media and the 'av' instance is returned. On this we subscribe to updates, and manage the 'av' streams in the same way as last time - with the processFeed() function.
If a chatroom doesn't yet exist, then we have to create one first. We do this with contact.chat(). Because the room creation is asynchronous, we populate the media object with the contact's ID and our media stream (to avoid having to request it from the user again) so that we can pass it to the callback.
The section of code that we ignored in the enableChat() callback above is now relevant because it will be called with the new chatroom. Here it is again:
/* If the room has come into existence due to a video request,
     start video with the stored stream */
if ( room.cID == media.cID && media.stream ) {
    console.log(TAG, 'New room, starting video chat');
    /* Listen for updates on the Av instance */
    room.videoChat(media.stream).addListener('update', processFeed);
    media = {};
}
If it's the room we're interested in (the one we just created) then its contact ID (cID) will match the one in the media object. We now have a room with the desired contact, and our own media stream so we are ready to call room.videoChat() in the same way as before. Finally, we empty the media object because we have finished passing the call information to the chat handler.
TEST - Load the page over HTTPS and login using incognito mode (Chrome) to avoid clashes. Start keevio and login as another user. In the incognito window, click the video icon next to the keevio user. This should show a video request in keevio.

Two way video, offer and accept

To extend our sample to accept an incoming call, we simply have to extend the processFeed() function which runs when there is an offered stream.
var accepted = {};

function processFeed(av) {
    /* Only process the Av instance if it has remote media */
    if ( typeof(av.remoteMedia) != 'object' )
        return;
    var videos = [];
    var feed = document.getElementById(av.id);
    for ( var id in av.remoteMedia ) {
        var video = document.getElementById(id);
        if ( av.remoteMedia[id].status == 'offered' ) {
            /* If the remote party is offering create an invite */
            if ( accepted[av.id] )
                return;
            console.log(TAG, 'Offer received from ' + av.remoteMedia[id].cN);
            /* Mark the offer as accepted as we may get another
               update with the 'offer' state still set */
            accepted[av.id] = true;
            invite = document.createElement('div');
            document.body.appendChild(invite);
            invite.innerHTML = 'VIDEO CHAT: ' + av.remoteMedia[id].cN;
            invite.className = 'invite';
            var accept = document.createElement('i');
            invite.appendChild(accept);
            accept.className = 'material-icons contact-accept';
            accept.innerHTML = 'done';
            accept.addEventListener('click',
                function() {
                    invite.parentNode.removeChild(invite);
                    /* Grab the user media and accept the offer with the returned stream */
                    navigator.mediaDevices.getUserMedia({audio: true, video: true}).then(
                        function(stream) {
                            av.accept(stream);
                        }
                    ).catch(
                        function() {
                            console.log(TAG, 'getUserMedia failed');
                        }
                    );
                }
            );
            /* Listen for updates to clean up the invite if the far end cancels */
            av.addListener('update',
                function(av) {
                    if ( av.state == 'closed' )
                        invite.parentNode.removeChild(invite);
                }
            );
        } else if ( av.remoteMedia[id].status == 'connected' && ! video ) {
            console.log(TAG, 'New remote media source ' + av.remoteMedia[id]);
            /* Create a new video tag to play/display the remote media */
            video = document.createElement('video');
            attachMediaStream(video, av.remoteMedia[id]);
            videos.push(video);
            video.id = id;
            video.play();
        } else if ( av.remoteMedia[id].status != 'connected' && video ) {
            /* Remove any video tags that are no longer in a 'connected' state */
            video.parentNode.removeChild(video);
        }
    }
    /* Create a feed container to hold video tags if it doesn't exist */
    if ( videos.length && ! feed ) {
        feed = document.createElement('div');
        document.body.appendChild(feed);
        feed.className = 'feed';
        feed.id = av.id;
    }
    /* Add the new video tags to the feed container */
    videos.forEach(
        function(video) {
            feed.appendChild(video);
        }
    );
    /* Remove the feed containers if empty */
    if ( feed && feed.children.length < 1 )
        feed.parentNode.removeChild(feed);
}
The main difference to note between this processFeed() and the previous is that we add an accept button when there is an incoming call. The click handler calls av.accept() function as before, except that it also passes the user's media stream.
We now have a two-way video calling application which displays a list of contacts for the user to initiate calls with. It also notifies the user of inbound calls and allows them to accept it.