Делаем видео-конференции в браузере за 10 минут
Видеоконференции через Skype уже давно заняли свое место в ежедневных коммуникациях, пользователи оценили удобство такого формата общения и все больше компаний стараются проводить встречи именно в этом формате. Но у скайпа есть большой минус: это отдельное приложение, которое трудно интегрировать в другой сервис. А сервисов, куда можно с пользой для дела встроить видеоконференции великое множество, начиная от систем бизнес-автоматизации и заканчивая сервисами группового обучения иностранному языку. Сегодня я покажу вам, как с помощью подручных средств и voximplant за 10 минут собрать движок видеоконференций, работающий прямо из браузера на webRTC и спозволяющий подключаться к конференции с обычных телефонов.
Voximplant использует профили пользователей, которые можно создавать с помощью HTTP API. Для демонстрации видеоконференции мы сделали небольшое приложение, которое по url-приглашению запрашивает имя участника, создает профиль пользователя и возвращает параметры аутентификации https://github.com/voximplant.
В отличие от звука, voximplant передает видео между участниками, peer-to-peer, что соответствует механике работы webRTC. Чтобы организовать конференцию, участникам необходимо сделать видео подключения друг к другу — это будет хорошо работать примерно до десяти пользователей, что с запасом покрывает большинство сценариев работы. А звук будет автоматически микшироваться стандартными механизмами voximplant. Для корректного микширования звука мы создадим две внутренние конференции: #1 для видеовызовов и #2 для участников с обычных телефонов:
Красные стрелки показывают аудио и видео потоки между участниками конференции в браузере, а синие стрелки показывают аудио-потоки для участников с телефонов. Одно из преимуществ voximplant — возможность гибкой работы с разными потоками на стороне облака, что позволяет создавать самые разные решения.
Для начала зарегистрируемся в voximplant.com и создадим новое приложение с именем «videoconf».
Затем в настройках этого приложения создадим первый, самый простой сценарий. Он будет отвечать за отправку p2p аудио/видео между web клиентами и называется «VideoConferenceP2P»:
VoxEngine.forwardCallToUserDirect();
Следующий сценарий в телефонии принято называть «gatekeeper» — он обрабатывает звонок от web-клиента и дальше перенаправляет его в конференцию с соответствующим conferenceID, полученным из webSDK, плюс обеспечивает передачу текстовых сообщений между конференцией и клиентом, для нотификации о подключении новых участников. Назовем этот сценарий «VideoConferenceGatekeeper»:
/**
* Video Conference Gatekeeper
* Handle inbound calls and route them to the conference
*/
var call,
conferenceId,
conf;
/**
* Inbound call handler
*/
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
// Get conference id from headers
conferenceId = e.headers['X-Conference-Id'];
Logger.write('User '+e.callerid+' is joining conference '+conferenceId);
call = e.call;
/**
* Play some audio till call connected event
*/
call.startEarlyMedia();
call.startPlayback("http://cdn.voximplant.com/bb_remix.mp3", true);
/**
* Add event listeners
*/
call.addEventListener(CallEvents.Connected, sdkCallConnected);
call.addEventListener(CallEvents.Disconnected, function (e) {
VoxEngine.terminate();
});
call.addEventListener(CallEvents.Failed, function (e) {
VoxEngine.terminate();
});
call.addEventListener(CallEvents.MessageReceived, function(e) {
Logger.write("Message Received: "+e.text);
try {
var msg = JSON.parse(e.text);
} catch(err) {
Logger.write(err);
}
if (msg.type == "ICE_FAILED") {
conf.sendMessage(e.text);
} else if (msg.type == "CALL_PARTICIPANT") {
conf.sendMessage(e.text);
}
});
// Answer the call
call.answer();
});
/**
* Connected handler
*/
function sdkCallConnected(e) {
// Stop playing audio
call.stopPlayback();
Logger.write('Joining conference');
// Call conference with specified id
conf = VoxEngine.callConference('conf_'+conferenceId, call.callerid(), call.displayName(), {"X-ClientType": "web"});
Logger.write('CallerID: '+call.callerid()+' DisplayName: '+call.displayName());
// Add event listeners
conf.addEventListener(CallEvents.Connected, function (e) {
Logger.write("VideoConference Connected");
VoxEngine.sendMediaBetween(conf, call);
});
conf.addEventListener(CallEvents.Disconnected, VoxEngine.terminate);
conf.addEventListener(CallEvents.Failed, VoxEngine.terminate);
conf.addEventListener(CallEvents.MessageReceived, function(e) {
call.sendMessage(e.text);
});
}
Следующий сценарий — для входящих звонков с обычных телефонов на телефонный номер конференции, который можно арендовать в пару кликов через интерфейс voximplant. После соединение синтезатор голоса промит звонящего ввести идентификатор конференции и осуществляет подключение. Назовем этот сценарий «VideoConferencePSTNgatekeeper»:
var pin = "", call;
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
call = e.call;
e.call.addEventListener(CallEvents.Connected, handleCallConnected);
e.call.addEventListener(CallEvents.Disconnected, handleCallDisconnected);
e.call.answer();
});
function handleCallConnected(e) {
e.call.say("Hello, please enter your conference pin using keypad and press pound key to join the conference.", Language.UK_ENGLISH_FEMALE);
e.call.addEventListener(CallEvents.ToneReceived, function (e) {
e.call.stopPlayback();
if (e.tone == "#") {
// Try to call conference according the specified pin
var conf = VoxEngine.callConference('conf_'+pin, e.call.callerid(), e.call.displayName(), {"X-ClientType": "pstn_inbound"});
conf.addEventListener(CallEvents.Connected, handleConfConnected);
conf.addEventListener(CallEvents.Failed, handleConfFailed);
} else {
pin += e.tone;
}
});
e.call.handleTones(true);
}
function handleConfConnected(e) {
VoxEngine.sendMediaBetween(e.call, call);
}
function handleConfFailed(e) {
VoxEngine.terminate();
}
function handleCallDisconnected(e) {
VoxEngine.terminate();
}
Последний и самый большой сценарий отвечает за создание двух конференций, подключение и отключение участников, управляет аудио потоками и удаляет ставшие не нужными профили отключившихся пользователей. Назовем этот сценарий «VideoConference», если вы будете копировать код из примера — не забудьте подставить свои значения «account_name» и «api_key»:
/**
* Require Conference module to get conferencing functionality
*/
require(Modules.Conference);
var videoconf,
pstnconf,
calls = [],
pstnCalls = [],
clientType,
/**
* HTTP API Access Info for user auto delete
*/
apiURL = "https://api.voximplant.com/platform_api",
account_name = "your_voximplant_account_name",
api_key = "your_voximplant_api_key";
// Add event handler for session start event
VoxEngine.addEventListener(AppEvents.Started, handleConferenceStarted);
function handleConferenceStarted(e) {
// Create 2 conferences right after session to manage audio in the right way
videoconf = VoxEngine.createConference();
pstnconf = VoxEngine.createConference();
}
/**
* Handle inbound call
*/
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
// get caller's client type
clientType = e.headers["X-ClientType"];
// Add event handlers depending on the client type
if (clientType == "web") {
e.call.addEventListener(CallEvents.Connected, handleParticipantConnected);
e.call.addEventListener(CallEvents.Disconnected, handleParticipantDisconnected);
} else {
pstnCalls.push(e.call);
e.call.addEventListener(CallEvents.Connected, handlePSTNParticipantConnected);
e.call.addEventListener(CallEvents.Disconnected, handlePSTNParticipantDisconnected);
}
e.call.addEventListener(CallEvents.Failed, handleConnectionFailed);
e.call.addEventListener(CallEvents.MessageReceived, handleMessageReceived);
// Answer the call
e.call.answer();
});
/**
* Message handler
*/
function handleMessageReceived(e) {
Logger.write("Message Recevied: " + e.text);
try {
var msg = JSON.parse(e.text);
} catch (err) {
Logger.write(err);
}
if (msg.type == "ICE_FAILED") {
// P2P call failed because of ICE problems - sending notification to retry
var caller = msg.caller.substr(0, msg.caller.indexOf('@'));
caller = caller.replace("sip:", "");
Logger.write("Sending notification to " + caller);
var call = getCallById(caller);
if (call != null) call.sendMessage(JSON.stringify({
type: "ICE_FAILED",
callee: msg.callee,
displayName: msg.displayName
}));
} else if (msg.type == "CALL_PARTICIPANT") {
// Conference participant decided to add PSTN participant (outbound call)
for (var k = 0; k < calls.length; k++) calls[k].sendMessage(e.text);
Logger.write("Calling participant with number " + msg.number);
var call = VoxEngine.callPSTN(msg.number);
pstnCalls.push(call);
call.addEventListener(CallEvents.Connected, handleOutboundCallConnected);
call.addEventListener(CallEvents.Disconnected, handleOutboundCallDisconnected);
call.addEventListener(CallEvents.Failed, handleOutboundCallFailed);
}
}
/**
* PSTN participant connected
*/
function handleOutboundCallConnected(e) {
e.call.say("You have joined a conference", Language.UK_ENGLISH_FEMALE);
e.call.addEventListener(CallEvents.PlaybackFinished, function (e) {
for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
type: "CALL_PARTICIPANT_CONNECTED",
number: e.call.number()
}));
VoxEngine.sendMediaBetween(e.call, pstnconf);
e.call.sendMediaTo(videoconf);
});
}
/**
* PSTN participant disconnected
*/
function handleOutboundCallDisconnected(e) {
Logger.write("PSTN participant disconnected " + e.call.number());
removePSTNparticipant(e.call);
for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
type: "CALL_PARTICIPANT_DISCONNECTED",
number: e.call.number()
}));
}
/**
* Call to PSTN participant failed
*/
function handleOutboundCallFailed(e) {
Logger.write("Call to PSTN participant " + e.call.number() + " failed");
removePSTNparticipant(e.call);
for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
type: "CALL_PARTICIPANT_FAILED",
number: e.call.number()
}));
}
function removePSTNparticipant(call) {
for (var i = 0; i < pstnCalls.length; i++) {
if (pstnCalls[i].number() == call.number()) {
Logger.write("Caller with number " + call.number() + " disconnected");
pstnCalls.splice(i, 1);
}
}
}
function handleConnectionFailed(e) {
Logger.write("Participant couldn't join the conference");
}
function participantExists(callerid) {
for (var i = 0; i < calls.length; i++) {
if (calls[i].callerid() == callerid) return true;
}
return false;
}
function getCallById(callerid) {
for (var i = 0; i < calls.length; i++) {
if (calls[i].callerid() == callerid) return calls[i];
}
return null;
}
/**
* Web client connected
*/
function handleParticipantConnected(e) {
if (!participantExists(e.call.callerid())) calls.push(e.call);
e.call.say("You have joined the conference.", Language.UK_ENGLISH_FEMALE);
e.call.addEventListener(CallEvents.PlaybackFinished, function (e) {
videoconf.sendMediaTo(e.call);
e.call.sendMediaTo(pstnconf);
sendCallsInfo();
});
}
function sendCallsInfo() {
var info = {
peers: [],
pstnCalls: []
};
for (var k = 0; k < calls.length; k++) {
info.peers.push({
callerid: calls[k].callerid(),
displayName: calls[k].displayName()
});
}
for (k = 0; k < pstnCalls.length; k++) {
info.pstnCalls.push({
callerid: pstnCalls[k].number()
});
}
for (var k = 0; k < calls.length; k++) {
calls[k].sendMessage(JSON.stringify(info));
}
}
/**
* Inbound PSTN call connected
*/
function handlePSTNParticipantConnected(e) {
e.call.say("You have joined the conference .", Language.UK_ENGLISH_FEMALE);
e.call.addEventListener(CallEvents.PlaybackFinished, function (e) {
VoxEngine.sendMediaBetween(e.call, pstnconf);
e.call.sendMediaTo(videoconf);
for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
type: "CALL_PARTICIPANT_CONNECTED",
number: e.call.callerid(),
inbound: true
}));
});
}
/**
* Web client disconnected
*/
function handleParticipantDisconnected(e) {
Logger.write("Disconnected:");
for (var i = 0; i < calls.length; i++) {
if (calls[i].callerid() == e.call.callerid()) {
/**
* Make HTTP request to delete user via HTTP API
*/
var url = apiURL + "/DelUser/?account_name=" + account_name +
"&api_key=" + api_key +
"&user_name=" + e.call.callerid();
Net.httpRequest(url, function (res) {
Logger.write("HttpRequest result: " + res.text);
});
Logger.write("Caller with id " + e.call.callerid() + " disconnected");
calls.splice(i, 1);
}
}
if (calls.length == 0) VoxEngine.terminate();
}
function handlePSTNParticipantDisconnected(e) {
removePSTNparticipant(e.call);
for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
type: "CALL_PARTICIPANT_DISCONNECTED",
number: e.call.callerid()
}));
}
Чтобы облако voximplant знало, когда выполнять какой сценарий, сценарии подключаются к приложению с помощью правил. Нам понадобятся следующие правила:
- InboundFromPSTN, в Pattern указываем телефонный номер конференции, в сценарии указываем «VideoConferencePSTNgatekeeper»
- InboundCall, в Pattern указываем строку «joinconf» (это номер, который мы будем набирать из Web SDK при подключении к конференции), в сценарии указываем «VideoConferenceGatekeeper»
- Fwd, в Pattern указываем строку «conf_[A-Za-z0–9]+», в сценарии указываем «VideoConference» — это правило будет срабатывать при звонке в конференцию через «callConference».
- P2P, в Pattern оставляем ».*», в сценарии указываем
- «VideoConferenceP2P»
Порядок расположения правил важен! Для перетаскивания (изменения приоритета) можно использовать drag’n'drop.
В результате настройки правил для приложения должны выглядит вот так:
Это все, что нужно настроить в облаке. Frontend часть сервиса делается с помощью нашего web sdk и довольно проста. После подключения нужно совершить звонок на «joinconf» и передать в заголовке «conferenceid». Когда пользователь становится участником конференции, в событии MessageReceived он получат список веб-клиентов и можно инициировать исходящие peer-to-peer звонки с помощью сценария «P2P» для получения видео от тех клиентов, к которым еще нет подключений. для включения именно P2P-режима передается специальный хедер «X-DirectCall» в методе «call». Также Frontend часть размещает на экране прямоугольники видеотрансляций и позволяет пригласить участника исходящим звонком из сценария конференции. Исходный код всех сценариев и клиентского приложения доступен на нашем GitHub-аккаунте