В этой главе мы собираемся создать клиентское приложение, которое позволит двум пользователям на разных устройствах общаться через WebRTC. Наше приложение будет иметь две страницы. Один для входа в систему, а другой для вызова другого пользователя.
Две страницы будут тегами div . Большая часть ввода осуществляется через простые обработчики событий.
Сигнальный сервер
Чтобы создать соединение WebRTC, клиенты должны иметь возможность передавать сообщения без использования однорангового соединения WebRTC. Здесь мы будем использовать HTML5 WebSockets — двунаправленное сокетное соединение между двумя конечными точками — веб-сервером и веб-браузером. Теперь давайте начнем использовать библиотеку WebSocket. Создайте файл server.js и вставьте следующий код —
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
Первая строка требует библиотеки WebSocket, которую мы уже установили. Затем мы создаем сервер сокетов на порту 9090. Затем мы прослушиваем событие подключения . Этот код будет выполнен, когда пользователь устанавливает соединение WebSocket с сервером. Затем мы слушаем любые сообщения, отправленные пользователем. Наконец, мы отправляем ответ подключенному пользователю, говоря «Привет с сервера».
На нашем сервере сигнализации мы будем использовать имя пользователя на основе строки для каждого соединения, чтобы мы знали, куда отправлять сообщения. Давайте немного изменим наш обработчик соединения —
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
Таким образом, мы принимаем только сообщения JSON. Далее нам нужно где-то хранить всех подключенных пользователей. Мы будем использовать простой объект Javascript для него. Изменить верх нашего файла —
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {};
Мы собираемся добавить поле типа для каждого сообщения от клиента. Например, если пользователь хочет войти, он отправляет сообщение типа входа . Давайте определим это —
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } });
Если пользователь отправляет сообщение с типом логина , мы —
-
Проверьте, если кто-то уже вошел в систему с этим именем пользователя
-
Если это так, то сообщите пользователю, что он не вошел в систему
-
Если никто не использует это имя пользователя, мы добавляем имя пользователя в качестве ключа к объекту подключения.
-
Если команда не распознана, мы отправляем ошибку.
Проверьте, если кто-то уже вошел в систему с этим именем пользователя
Если это так, то сообщите пользователю, что он не вошел в систему
Если никто не использует это имя пользователя, мы добавляем имя пользователя в качестве ключа к объекту подключения.
Если команда не распознана, мы отправляем ошибку.
Следующий код является вспомогательной функцией для отправки сообщений в соединение. Добавьте его в файл server.js —
function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
Когда пользователь отключается, мы должны очистить его соединение. Мы можем удалить пользователя при возникновении события закрытия . Добавьте следующий код в обработчик соединения —
connection.on("close", function() { if(connection.name) { delete users[connection.name]; } });
После успешного входа в систему пользователь хочет позвонить другому. Он должен сделать предложение другому пользователю для достижения этого. Добавить обработчик предложения —
case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break;
Во-первых, мы получаем соединение с пользователем, которого пытаемся вызвать. Если он существует, мы отправляем ему детали предложения . Мы также добавляем otherName к объекту соединения . Это сделано для простоты поиска позже.
Ответ на ответ имеет аналогичную схему, которую мы использовали в обработчике предложений . Наш сервер просто проходит через все сообщения как ответ другому пользователю. Добавьте следующий код после обработчика предложения —
case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
Заключительная часть — обработка кандидата ICE между пользователями. Мы используем ту же технику, просто передавая сообщения между пользователями. Основное отличие состоит в том, что сообщения-кандидаты могут появляться несколько раз на пользователя в любом порядке. Добавьте обработчик кандидата —
case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break;
Чтобы позволить нашим пользователям отключаться от другого пользователя, мы должны реализовать функцию зависания. Он также скажет серверу удалить все пользовательские ссылки. Добавьте обработчик отпуска —
case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break;
Это также отправит другому пользователю событие ухода, чтобы он мог соответствующим образом отключить одноранговое соединение. Также следует разобраться со случаем, когда пользователь сбрасывает соединение с сервера сигнализации. Давайте изменим наш близкий обработчик —
connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } });
Ниже приведен полный код нашего сервера сигнализации.
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {}; //when a user connects to our sever wss.on('connection', function(connection) { console.log("User connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); connection.send("Hello world"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
Клиентское приложение
Один из способов проверить это приложение — открыть две вкладки браузера и попытаться вызвать друг друга.
Прежде всего, нам нужно установить библиотеку начальной загрузки . Bootstrap — это интерфейс для разработки веб-приложений. Вы можете узнать больше на http://getbootstrap.com/. Создайте папку с именем, например, «видеочат». Это будет наша корневая папка приложения. Внутри этой папки создайте файл package.json (он необходим для управления зависимостями npm) и добавьте следующее:
{ "name": "webrtc-videochat", "version": "0.1.0", "description": "webrtc-videochat", "author": "Author", "license": "BSD-2-Clause" }
Затем запустите npm install bootstrap . Это установит библиотеку начальной загрузки в папку videochat / node_modules .
Теперь нам нужно создать простую HTML-страницу. Создайте файл index.html в корневой папке со следующим кодом —
<html> <head> <title>WebRTC Video Demo</title> <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/> </head> <style> body { background: #eee; padding: 5% 0; } video { background: black; border: 1px solid gray; } .call-page { position: relative; display: block; margin: 0 auto; width: 500px; height: 500px; } #localVideo { width: 150px; height: 150px; position: absolute; top: 15px; right: 15px; } #remoteVideo { width: 500px; height: 500px; } </style> <body> <div id = "loginPage" class = "container text-center"> <div class = "row"> <div class = "col-md-4 col-md-offset-4"> <h2>WebRTC Video Demo. Please sign in</h2> <label for = "usernameInput" class = "sr-only">Login</label> <input type = "email" id = "usernameInput" c lass = "form-control formgroup" placeholder = "Login" required = "" autofocus = ""> <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock"> Sign in</button> </div> </div> </div> <div id = "callPage" class = "call-page"> <video id = "localVideo" autoplay></video> <video id = "remoteVideo" autoplay></video> <div class = "row text-center"> <div class = "col-md-12"> <input id = "callToUsernameInput" type = "text" placeholder = "username to call" /> <button id = "callBtn" class = "btn-success btn">Call</button> <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> </div> </div> </div> <script src = "client.js"></script> </body> </html>
Эта страница должна быть вам знакома. Мы добавили файл начальной загрузки css. Мы также определили две страницы. Наконец, мы создали несколько текстовых полей и кнопок для получения информации от пользователя. Вы должны увидеть два видеоэлемента для локальных и удаленных видеопотоков. Обратите внимание, что мы добавили ссылку на файл client.js .
Теперь нам нужно установить соединение с нашим сервером сигнализации. Создайте файл client.js в корневой папке со следующим кодом —
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://localhost:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); };
Теперь запустите наш сервер сигнализации через сервер узлов . Затем внутри корневой папки запустите статическую команду и откройте страницу в браузере. Вы должны увидеть следующий вывод консоли —
Следующим шагом является реализация входа пользователя с уникальным именем пользователя. Мы просто отправляем имя пользователя на сервер, который затем сообщает нам, занято оно или нет. Добавьте следующий код в ваш файл client.js —
//****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); //hide call page callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { //display the call page if login is successful loginPage.style.display = "none"; callPage.style.display = "block"; //start peer connection } };
Во-первых, мы выбираем несколько ссылок на элементы на странице. Мы скрываем страницу вызова. Затем мы добавляем прослушиватель событий на кнопку входа. Когда пользователь щелкает по нему, мы отправляем его имя пользователя на сервер. Наконец, мы реализуем обратный вызов handleLogin. Если вход был успешным, мы показываем страницу вызова и начинаем устанавливать одноранговое соединение.
Чтобы начать одноранговое соединение нам нужно —
- Получить поток с веб-камеры.
- Создайте объект RTCPeerConnection.
Добавьте следующий код в «блок селекторов пользовательского интерфейса» —
var localVideo = document.querySelector('#localVideo'); var remoteVideo = document.querySelector('#remoteVideo'); var yourConn; var stream;
Изменить функцию handleLogin —
function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //getting local video stream navigator.webkitGetUserMedia({ video: true, audio: true }, function (myStream) { stream = myStream; //displaying local video stream on the page localVideo.src = window.URL.createObjectURL(stream); //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration); // setup stream listening yourConn.addStream(stream); //when a remote user adds stream to the peer connection, we display it yourConn.onaddstream = function (e) { remoteVideo.src = window.URL.createObjectURL(e.stream); }; // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; }, function (error) { console.log(error); }); } };
Теперь, если вы запустите код, страница должна позволить вам войти в систему и отобразить ваш локальный видеопоток на странице.
Теперь мы готовы инициировать вызов. Сначала мы отправляем предложение другому пользователю. Как только пользователь получает предложение, он создает ответ и начинает торговать кандидатами ICE. Добавьте следующий код в файл client.js —
//initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); };
Мы добавляем обработчик кликов к кнопке Call, которая инициирует предложение. Затем мы реализуем несколько обработчиков, ожидаемых обработчиком onmessage . Они будут обрабатываться асинхронно, пока оба пользователя не установят соединение.
Последний шаг — реализация функции зависания. Это прекратит передачу данных и скажет другому пользователю закрыть вызов. Добавьте следующий код —
//hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; remoteVideo.src = null; yourConn.close(); yourConn.onicecandidate = null; yourConn.onaddstream = null; };
Когда пользователь нажимает кнопку Hang Up —
- Он отправит сообщение «оставить» другому пользователю
- Это закроет RTCPeerConnection и уничтожит соединение локально
Теперь запустите код. Вы должны иметь возможность войти на сервер, используя две вкладки браузера. Затем вы можете позвонить на вкладку и повесить трубку.
Ниже приведен весь файл client.js —
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://localhost:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); }; //****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); var localVideo = document.querySelector('#localVideo'); var remoteVideo = document.querySelector('#remoteVideo'); var yourConn; var stream; callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //getting local video stream navigator.webkitGetUserMedia({ video: true, audio: true }, function (myStream) { stream = myStream; //displaying local video stream on the page localVideo.src = window.URL.createObjectURL(stream); //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration); // setup stream listening yourConn.addStream(stream); //when a remote user adds stream to the peer connection, we display it yourConn.onaddstream = function (e) { remoteVideo.src = window.URL.createObjectURL(e.stream); }; // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; }, function (error) { console.log(error); }); } }; //initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); }; //hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; remoteVideo.src = null; yourConn.close(); yourConn.onicecandidate = null; yourConn.onaddstream = null; };
Резюме
Эта демонстрация предоставляет базовый набор функций, которые нужны каждому приложению WebRTC. Чтобы улучшить эту демонстрацию, вы можете добавить идентификацию пользователя с помощью таких платформ, как Facebook или Google, обрабатывать ввод пользователя для неверных данных. Кроме того, соединение WebRTC может прерваться из-за нескольких причин, таких как не поддержка технологии или неспособность пройти через брандмауэры. Работа над тем, чтобы сделать любое приложение WebRTC стабильным.