// WebSocket and WebRTC based multi-user chat sample with two-way video // calling, including use of TURN if applicable or necessary. // // This file contains the JavaScript code that implements the client-side // features for connecting and managing chat and video calls. // // To read about how this sample works: http://bit.ly/webrtc-from-chat // // Any copyright is dedicated to the Public Domain. // http://creativecommons.org/publicdomain/zero/1.0/ "use strict"; // Get our hostname var myHostname = window.location.hostname; console.log("Hostname: " + myHostname); // WebSocket chat/signaling channel variables. var connection = null; var clientID = 0; // The media constraints object describes what sort of stream we want // to request from the local A/V hardware (typically a webcam and // microphone). Here, we specify only that we want both audio and // video; however, you can be more specific. It's possible to state // that you would prefer (or require) specific resolutions of video, // whether to prefer the user-facing or rear-facing camera (if available), // and so on. // // See also: // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints // https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia // var mediaConstraints = { audio: true, // We want an audio track video: true // ...and we want a video track }; var myUsername = null; var targetUsername = null; // To store username of other peer var myPeerConnection = null; // RTCPeerConnection // To work both with and without addTrack() we need to note // if it's available var hasAddTrack = false; // Output logging information to console. function log(text) { var time = new Date(); console.log("[" + time.toLocaleTimeString() + "] " + text); } // Output an error message to console. function log_error(text) { var time = new Date(); console.error("[" + time.toLocaleTimeString() + "] " + text); } // Send a JavaScript object by converting it to JSON and sending // it as a message on the WebSocket connection. function sendToServer(msg) { var msgJSON = JSON.stringify(msg); log("Sending '" + msg.type + "' message: " + msgJSON); connection.send(msgJSON); } // Called when the "id" message is received; this message is sent by the // server to assign this login session a unique ID number; in response, // this function sends a "username" message to set our username for this // session. function setUsername() { myUsername = document.getElementById("name").value; sendToServer({ name: myUsername, date: Date.now(), id: clientID, type: "username" }); } // Open and configure the connection to the WebSocket server. function connect() { var serverUrl; var scheme = "ws"; // If this is an HTTPS connection, we have to use a secure WebSocket // connection too, so add another "s" to the scheme. if (document.location.protocol === "https:") { scheme += "s"; } serverUrl = scheme + "://" + myHostname + ":443"; connection = new WebSocket(serverUrl, "json"); connection.onopen = function(evt) { }; connection.onerror = function(evt) { console.dir(evt); } connection.onmessage = function(evt) { var text = ""; var msg = JSON.parse(evt.data); log("Message received: "); console.dir(msg); var time = new Date(msg.date); var timeStr = time.toLocaleTimeString(); switch(msg.type) { case "id": clientID = msg.id; setUsername(); break; case "rejectusername": myUsername = msg.name; break; case "userlist": // Received an updated user list handleUserlistMsg(msg); break; // Signaling messages: these messages are used to trade WebRTC // signaling information during negotiations leading up to a video // call. case "video-offer": // Invitation and offer to chat handleVideoOfferMsg(msg); break; case "video-answer": // Callee has answered our offer handleVideoAnswerMsg(msg); break; case "new-ice-candidate": // A new ICE candidate has been received handleNewICECandidateMsg(msg); break; case "hang-up": // The other peer has hung up the call handleHangUpMsg(msg); break; // Unknown message; output to console for debugging. default: log_error("Unknown message received:"); log_error(msg); } }; } // Create the RTCPeerConnection which knows how to talk to our // selected STUN/TURN server and then uses getUserMedia() to find // our camera and microphone and add that stream to the connection for // use in our video call. Then we configure event handlers to get // needed notifications on the call. function createPeerConnection() { log("Setting up a connection..."); // Create an RTCPeerConnection which knows to use our chosen // STUN server. myPeerConnection = new RTCPeerConnection({ iceServers: [ // Information about ICE servers - Use your own! { url: 'stun:stun.l.google.com:19302' }, { url: 'turn:numb.viagenie.ca', credential: 'muazkh', username: 'webrtc@live.com' } ] }); // Do we have addTrack()? If not, we will use streams instead. hasAddTrack = (myPeerConnection.addTrack !== undefined); // Set up event handlers for the ICE negotiation process. myPeerConnection.onicecandidate = handleICECandidateEvent; myPeerConnection.onremovestream = handleRemoveStreamEvent; myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent; myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent; myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent; myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent; // Because the deprecation of addStream() and the addstream event is recent, // we need to use those if addTrack() and track aren't available. if (hasAddTrack) { myPeerConnection.ontrack = handleTrackEvent; } else { myPeerConnection.onaddstream = handleAddStreamEvent; } } // Called by the WebRTC layer to let us know when it's time to // begin (or restart) ICE negotiation. Starts by creating a WebRTC // offer, then sets it as the description of our local media // (which configures our local media stream), then sends the // description to the callee as an offer. This is a proposed media // format, codec, resolution, etc. function handleNegotiationNeededEvent() { log("*** Negotiation needed"); log("---> Creating offer"); myPeerConnection.createOffer().then(function(offer) { log("---> Creating new description object to send to remote peer"); return myPeerConnection.setLocalDescription(offer); }) .then(function() { log("---> Sending offer to remote peer"); sendToServer({ name: myUsername, target: targetUsername, type: "video-offer", sdp: myPeerConnection.localDescription }); }) .catch(reportError); } // Called by the WebRTC layer when events occur on the media tracks // on our WebRTC call. This includes when streams are added to and // removed from the call. // // track events include the following fields: // // RTCRtpReceiver receiver // MediaStreamTrack track // MediaStream[] streams // RTCRtpTransceiver transceiver function handleTrackEvent(event) { log("*** Track event"); document.getElementById("received_video").srcObject = event.streams[0]; document.getElementById("hangup-button").disabled = false; } // Called by the WebRTC layer when a stream starts arriving from the // remote peer. We use this to update our user interface, in this // example. function handleAddStreamEvent(event) { log("*** Stream added"); document.getElementById("received_video").srcObject = event.stream; document.getElementById("hangup-button").disabled = false; } // An event handler which is called when the remote end of the connection // removes its stream. We consider this the same as hanging up the call. // It could just as well be treated as a "mute". // // Note that currently, the spec is hazy on exactly when this and other // "connection failure" scenarios should occur, so sometimes they simply // don't happen. function handleRemoveStreamEvent(event) { log("*** Stream removed"); closeVideoCall(); } // Handles |icecandidate| events by forwarding the specified // ICE candidate (created by our local ICE agent) to the other // peer through the signaling server. function handleICECandidateEvent(event) { if (event.candidate) { log("Outgoing ICE candidate: " + event.candidate.candidate); sendToServer({ type: "new-ice-candidate", target: targetUsername, candidate: event.candidate }); } } // Handle |iceconnectionstatechange| events. This will detect // when the ICE connection is closed, failed, or disconnected. // // This is called when the state of the ICE agent changes. function handleICEConnectionStateChangeEvent(event) { log("*** ICE connection state changed to " + myPeerConnection.iceConnectionState); switch(myPeerConnection.iceConnectionState) { case "closed": case "failed": case "disconnected": closeVideoCall(); break; } } // Set up a |signalingstatechange| event handler. This will detect when // the signaling connection is closed. // // NOTE: This will actually move to the new RTCPeerConnectionState enum // returned in the property RTCPeerConnection.connectionState when // browsers catch up with the latest version of the specification! function handleSignalingStateChangeEvent(event) { log("*** WebRTC signaling state changed to: " + myPeerConnection.signalingState); switch(myPeerConnection.signalingState) { case "closed": closeVideoCall(); break; } } // Handle the |icegatheringstatechange| event. This lets us know what the // ICE engine is currently working on: "new" means no networking has happened // yet, "gathering" means the ICE engine is currently gathering candidates, // and "complete" means gathering is complete. Note that the engine can // alternate between "gathering" and "complete" repeatedly as needs and // circumstances change. // // We don't need to do anything when this happens, but we log it to the // console so you can see what's going on when playing with the sample. function handleICEGatheringStateChangeEvent(event) { log("*** ICE gathering state changed to: " + myPeerConnection.iceGatheringState); } // Given a message containing a list of usernames, this function // populates the user list box with those names, making each item // clickable to allow starting a video call. function handleUserlistMsg(msg) { var i; var listElem = document.getElementById("userlistbox"); // Remove all current list members. We could do this smarter, // by adding and updating users instead of rebuilding from // scratch but this will do for this sample. while (listElem.firstChild) { listElem.removeChild(listElem.firstChild); } // Add member names from the received list for (i=0; i < msg.users.length; i++) { var item = document.createElement("li"); item.appendChild(document.createTextNode(msg.users[i])); item.addEventListener("click", invite, false); listElem.appendChild(item); } } // Close the RTCPeerConnection and reset variables so that the user can // make or receive another call if they wish. This is called both // when the user hangs up, the other user hangs up, or if a connection // failure is detected. function closeVideoCall() { var remoteVideo = document.getElementById("received_video"); var localVideo = document.getElementById("local_video"); log("Closing the call"); // Close the RTCPeerConnection if (myPeerConnection) { log("--> Closing the peer connection"); // Disconnect all our event listeners; we don't want stray events // to interfere with the hangup while it's ongoing. myPeerConnection.onaddstream = null; // For older implementations myPeerConnection.ontrack = null; // For newer ones myPeerConnection.onremovestream = null; myPeerConnection.onnicecandidate = null; myPeerConnection.oniceconnectionstatechange = null; myPeerConnection.onsignalingstatechange = null; myPeerConnection.onicegatheringstatechange = null; myPeerConnection.onnotificationneeded = null; // Stop the videos if (remoteVideo.srcObject) { remoteVideo.srcObject.getTracks().forEach(track => track.stop()); } if (localVideo.srcObject) { localVideo.srcObject.getTracks().forEach(track => track.stop()); } remoteVideo.src = null; localVideo.src = null; // Close the peer connection myPeerConnection.close(); myPeerConnection = null; } // Disable the hangup button document.getElementById("hangup-button").disabled = true; targetUsername = null; } // Handle the "hang-up" message, which is sent if the other peer // has hung up the call or otherwise disconnected. function handleHangUpMsg(msg) { log("*** Received hang up notification from other peer"); closeVideoCall(); } // Hang up the call by closing our end of the connection, then // sending a "hang-up" message to the other peer (keep in mind that // the signaling is done on a different connection). This notifies // the other peer that the connection should be terminated and the UI // returned to the "no call in progress" state. function hangUpCall() { closeVideoCall(); sendToServer({ name: myUsername, target: targetUsername, type: "hang-up" }); } // Handle a click on an item in the user list by inviting the clicked // user to video chat. Note that we don't actually send a message to // the callee here -- calling RTCPeerConnection.addStream() issues // a |notificationneeded| event, so we'll let our handler for that // make the offer. function invite(evt) { log("Starting to prepare an invitation"); if (myPeerConnection) { alert("You can't start a call because you already have one open!"); } else { var clickedUsername = evt.target.textContent; // Don't allow users to call themselves, because weird. if (clickedUsername === myUsername) { alert("I'm afraid I can't let you talk to yourself. That would be weird."); return; } // Record the username being called for future reference targetUsername = clickedUsername; log("Inviting user " + targetUsername); // Call createPeerConnection() to create the RTCPeerConnection. log("Setting up connection to invite user: " + targetUsername); createPeerConnection(); // Now configure and create the local stream, attach it to the // "preview" box (id "local_video"), and add it to the // RTCPeerConnection. log("Requesting webcam access..."); navigator.mediaDevices.getUserMedia(mediaConstraints) .then(function(localStream) { log("-- Local video stream obtained"); document.getElementById("local_video").srcObject = localStream; if (hasAddTrack) { log("-- Adding tracks to the RTCPeerConnection"); localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream)); } else { log("-- Adding stream to the RTCPeerConnection"); myPeerConnection.addStream(localStream); } }) .catch(handleGetUserMediaError); } } // Accept an offer to video chat. We configure our local settings, // create our RTCPeerConnection, get and attach our local camera // stream, then create and send an answer to the caller. function handleVideoOfferMsg(msg) { var localStream = null; targetUsername = msg.name; // Call createPeerConnection() to create the RTCPeerConnection. log("Starting to accept invitation from " + targetUsername); createPeerConnection(); // We need to set the remote description to the received SDP offer // so that our local WebRTC layer knows how to talk to the caller. var desc = new RTCSessionDescription(msg.sdp); myPeerConnection.setRemoteDescription(desc).then(function () { log("Setting up the local media stream..."); return navigator.mediaDevices.getUserMedia(mediaConstraints); }) .then(function(stream) { log("-- Local video stream obtained"); localStream = stream; document.getElementById("local_video").srcObject = localStream; if (hasAddTrack) { log("-- Adding tracks to the RTCPeerConnection"); localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream) ); } else { log("-- Adding stream to the RTCPeerConnection"); myPeerConnection.addStream(localStream); } }) .then(function() { log("------> Creating answer"); // Now that we've successfully set the remote description, we need to // start our stream up locally then create an SDP answer. This SDP // data describes the local end of our call, including the codec // information, options agreed upon, and so forth. return myPeerConnection.createAnswer(); }) .then(function(answer) { log("------> Setting local description after creating answer"); // We now have our answer, so establish that as the local description. // This actually configures our end of the call to match the settings // specified in the SDP. return myPeerConnection.setLocalDescription(answer); }) .then(function() { var msg = { name: myUsername, target: targetUsername, type: "video-answer", sdp: myPeerConnection.localDescription }; // We've configured our end of the call now. Time to send our // answer back to the caller so they know that we want to talk // and how to talk to us. log("Sending answer packet back to other peer"); sendToServer(msg); }) .catch(handleGetUserMediaError); } // Responds to the "video-answer" message sent to the caller // once the callee has decided to accept our request to talk. function handleVideoAnswerMsg(msg) { log("Call recipient has accepted our call"); // Configure the remote description, which is the SDP payload // in our "video-answer" message. var desc = new RTCSessionDescription(msg.sdp); myPeerConnection.setRemoteDescription(desc).catch(reportError); } // A new ICE candidate has been received from the other peer. Call // RTCPeerConnection.addIceCandidate() to send it along to the // local ICE framework. function handleNewICECandidateMsg(msg) { var candidate = new RTCIceCandidate(msg.candidate); log("Adding received ICE candidate: " + JSON.stringify(candidate)); myPeerConnection.addIceCandidate(candidate) .catch(reportError); } // Handle errors which occur when trying to access the local media // hardware; that is, exceptions thrown by getUserMedia(). The two most // likely scenarios are that the user has no camera and/or microphone // or that they declined to share their equipment when prompted. If // they simply opted not to share their media, that's not really an // error, so we won't present a message in that situation. function handleGetUserMediaError(e) { log(e); switch(e.name) { case "NotFoundError": alert("Unable to open your call because no camera and/or microphone" + "were found."); break; case "SecurityError": case "PermissionDeniedError": // Do nothing; this is the same as the user canceling the call. break; default: alert("Error opening your camera and/or microphone: " + e.message); break; } // Make sure we shut down our end of the RTCPeerConnection so we're // ready to try again. closeVideoCall(); } // Handles reporting errors. Currently, we just dump stuff to console but // in a real-world application, an appropriate (and user-friendly) // error message should be displayed. function reportError(errMessage) { log_error("Error " + errMessage.name + ": " + errMessage.message); }