Spec-Zone.ru › Web APIs

Сигнализация и видеозвонки

WebRTC позволяет осуществлять обмен медиаданными в режиме реального времени между двумя устройствами по принципу «точка-точка». Подключение устанавливается посредством процесса обнаружения и согласования, называемого сигнализацией. Этот учебник поможет вам создать двусторонний видеозвонок.

WebRTC — это полностью децентрализованная технология для обмена аудио, видео и данными в режиме реального времени, с одним важным исключением. Для того, чтобы два устройства на разных сетях смогли найти друг друга, необходимо провести процедуру обнаружения и согласования форматов медиа, как описано в другом месте. Этот процесс называется сигнализацией и подразумевает подключение обоих устройств к третьему, согласованному серверу. Через этот промежуточный сервер два устройства могут найти друг друга и обменяться сообщениями согласования.

В этой статье мы дополним чат WebSocket, первоначально созданный в рамках документации WebSocket (ссылка на эту статью будет предоставлена позже; она пока не опубликована), для поддержки открытия двустороннего видеозвонка между пользователями. Вы можете попробовать этот пример на Glitch, и вы можете переработать пример, чтобы поэкспериментировать с ним. Вы также можете посмотреть весь проект на GitHub:

Примечание: Если вы будете пробовать пример на Glitch, обратите внимание, что любые изменения в коде немедленно приведут к разрыву всех подключений. Кроме того, существует небольшой таймаут; экземпляр Glitch предназначен только для быстрых экспериментов и тестирования.

Сервер сигнализации

Для установления соединения WebRTC между двумя устройствами необходим сервер сигнализации, чтобы решить, как подключить их через интернет. Задача сервера сигнализации — выступать в качестве посредника, позволяющего двум узлам найти и установить соединение, минимизируя раскрытие потенциально конфиденциальной информации. Как создать этот сервер и как на самом деле работает процесс сигнализации?

Сначала нам нужен сам сервер сигнализации. WebRTC не определяет механизм передачи для информации сигнализации. Вы можете использовать любой механизм, начиная от WebSocket и заканчивая fetch(), голубиной почтой, чтобы обмениваться информацией сигнализации между двумя узлами.

Важно отметить, что серверу не нужно понимать или интерпретировать содержимое данных сигнализации. Хотя это SDP, даже это не так важно: содержание сообщения, проходящего через сервер сигнализации, по сути, является «черным ящиком». Важно то, что когда подсистема ICE инструктирует вас отправить данные сигнализации другому узлу, вы делаете это, а другой узел знает, как принять эту информацию и передать ее собственной подсистеме ICE. Вам нужно только направлять информацию туда-сюда. Содержимое совершенно неважно для сервера сигнализации.

Подготовка сервера чата для сигнализации

Наш сервер чата использует API WebSocket для отправки информации в виде строк JSON между каждым клиентом и сервером. Сервер поддерживает несколько типов сообщений для выполнения задач, таких как регистрация новых пользователей, установка имен пользователей и отправка публичных сообщений в чат.

Чтобы сервер мог поддерживать сигнализацию и согласование ICE, нам нужно обновить код. Нам нужно будет разрешить отправку сообщений одному конкретному пользователю вместо трансляции всем подключенным пользователям и гарантировать, что типы неизвестных сообщений будут переданы и доставлены без необходимости серверу знать, что они представляют собой. Это позволит нам отправлять сообщения сигналов с помощью этого же сервера, вместо необходимости в отдельном сервере.

Давайте посмотрим на изменения, которые нам нужно внести в сервер чата для поддержки сигнальной функции WebRTC. Это находится в файле chatserver.js.

Первым делом идёт добавление функции sendToOneUser(). Как следует из названия, она отправляет строковое JSON-сообщение конкретному пользователю по имени.

function sendToOneUser(target, msgString) {
  connectionArray.find((conn) => conn.username === target).send(msgString);
}

Эта функция перебирает список подключенных пользователей, пока не найдёт пользователя с указанным именем, а затем отправляет сообщение этому пользователю. Параметр msgString представляет собой строковое JSON-объект. Мы могли бы заставить её принимать наш исходный объект сообщения, но в этом примере это более эффективно. Поскольку сообщение уже сериализовано в строку, мы можем отправить его без дополнительной обработки. Каждый элемент в connectionArray — это объект WebSocket, поэтому мы можем просто вызвать его метод send() напрямую.

Наш исходный демонстрационный чат не поддерживал отправку сообщений конкретному пользователю. Следующей задачей является обновление основного обработчика сообщений WebSocket для поддержки этого. Это включает изменение в конце обработчика сообщений "connection":

if (sendToClients) {
  const msgString = JSON.stringify(msg);

  if (msg.target && msg.target.length !== 0) {
    sendToOneUser(msg.target, msgString);
  } else {
    for (const connection of connectionArray) {
      connection.send(msgString);
    }
  }
}

Теперь этот код проверяет, есть ли у ожидаемого сообщения свойство target. Если это свойство есть, оно указывает имя пользователя клиента, которому должно быть отправлено сообщение, и мы вызываем sendToOneUser() для отправки сообщения ему. В противном случае сообщение транслируется всем пользователям, перебирая список подключений и отправляя сообщение каждому пользователю.

Поскольку существующий код позволяет отправлять произвольные типы сообщений, дополнительные изменения не требуются. Теперь наши клиенты могут отправлять сообщения неизвестных типов любому конкретному пользователю, позволяя им отправлять сообщения сигнализации друг другу, как им необходимо.

Это всё, что нам нужно изменить на стороне сервера. Теперь давайте рассмотрим протокол сигнализации, который мы будем реализовывать.

Разработка протокола сигнализации

Теперь, когда мы построили механизм для обмена сообщениями, нам нужен протокол, определяющий, как будут выглядеть эти сообщения. Это можно сделать несколькими способами; демонстрируемый здесь — всего лишь один возможный способ структурирования сообщений сигнализации.

Сервер в этом примере использует строковые JSON-объекты для общения со своими клиентами. Это означает, что наши сообщения сигнализации будут в формате JSON, с содержимым, которое указывает, какого рода сообщениями они являются, а также любую дополнительную информацию, необходимую для надлежащего обработки сообщений.

Обмен описаниями сеансов

При запуске процесса сигнализации пользователь, инициирующий вызов, создаёт предложение. Это предложение включает описание сеанса в формате SDP и должно быть доставлено принимающему пользователю, которого мы будем называть получателем. Получатель отвечает на предложение сообщением ответ, также содержащим описание SDP. Наш сервер сигнализации будет использовать WebSocket для передачи сообщений предложения с типом "video-offer", и сообщений ответа с типом "video-answer". Эти сообщения имеют следующие поля:

type

Тип сообщения; либо "video-offer" или "video-answer".

name

Имя пользователя отправителя.

target

Имя пользователя получателя описания (если отправитель отправляет сообщение, это указывает на получателя, и наоборот).

sdp

Строка SDP (протокол описания сеанса), описывающая локальный конец соединения с точки зрения отправителя (или удалённый конец соединения с точки зрения получателя).

На данном этапе оба участника знают, какие кодеки и параметры кодеков будут использоваться для этого вызова. Однако они всё ещё не знают, как передавать сами данные медиа. Здесь на сцену выходит Interactive Connectivity Establishment (ICE).

Обмен кандидатами ICE

Два участника должны обмениваться кандидатами ICE для согласования фактического соединения между ними. Каждый кандидат ICE описывает метод, который отправляющий участник может использовать для связи. Каждый участник отправляет кандидатов в порядке их обнаружения и продолжает отправлять кандидатов до тех пор, пока у него не закончатся предложения, даже если медиапоток уже начался.

Событие icecandidate отправляется в RTCPeerConnection для завершения процесса добавления локального описания с использованием pc.setLocalDescription(offer).

После того, как оба участника согласятся на взаимно совместимого кандидата, SDP этого кандидата используется каждым участником для построения и открытия соединения, по которому затем начнёт передаваться медиа. Если они позже согласятся на лучшего (обычно более производительного) кандидата, формат потока может измениться по мере необходимости.

Хотя в настоящее время это не поддерживается, кандидат, полученный после начала потока медиа, теоретически также может быть использован для понижения скорости соединения, если это необходимо.

Каждый кандидат ICE отправляется другому участнику путём отправки JSON-сообщения типа "new-ice-candidate" через сервер сигнализации удалённому участнику. Каждое сообщение кандидата включает следующие поля:

type

Тип сообщения: "new-ice-candidate".

target

Имя пользователя того человека, с которым происходит согласование; сервер направит сообщение только этому пользователю.

candidate

Строка кандидата SDP, описывающая предлагаемый метод соединения. Обычно вам не нужно смотреть на содержимое этой строки. Всё, что нужно вашей программе, — это передать её удалённому участнику с использованием сервера сигнализации.

Каждое сообщение ICE предполагает протокол связи (TCP или UDP), IP-адрес, номер порта, тип соединения (например, является ли указанный IP-адрес самим участником или сервером ретрансляции), а также другую информацию, необходимую для соединения двух компьютеров. Это включает в себя NAT или другие сложности сетевого взаимодействия.

Примечание: Важно отметить следующее: единственная задача вашей программы во время согласования ICE — это принятие исходящих кандидатов от уровня ICE и отправка их через соединение сигнализации другому участнику при выполнении обработчика onicecandidate, а также получение сообщений кандидатов ICE от сервера сигнализации (при получении сообщения "new-ice-candidate") и передачу их вашему уровню ICE, вызвав RTCPeerConnection.addIceCandidate(). Всё.

Содержимое SDP в подавляющем большинстве случаев для вас не имеет значения. Не стоит пытаться усложнять это, пока вы не поймёте, что делаете. В противном случае это может привести к ошибкам.

Всё, что теперь нужно сделать вашему серверу сигнализации, — это отправлять запрошенные сообщения. Ваш рабочий процесс также может потребовать функций входа/аутентификации, но такие детали могут различаться.

Примечание: Событие onicecandidate и обещание createAnswer() — это асинхронные вызовы, которые обрабатываются отдельно. Убедитесь, что ваша сигнализация не меняет порядок! Например, addIceCandidate() с кандидатами ICE сервера должно быть выполнено после установки ответа с помощью setRemoteDescription().

Поток транзакций сигнализации

Процесс сигнализации включает в себя обмен сообщениями между двумя участниками с использованием посредника — сервера сигнализации. Конечно, точный процесс будет различаться, но в общем есть несколько ключевых моментов, в которых обрабатываются сообщения сигнализации:

  • Клиент каждого пользователя, работающий в веб-браузере
  • Веб-браузер каждого пользователя
  • Сервер сигнализации
  • Веб-сервер, на котором размещена служба чата

Представьте, что Наоми и Прийя участвуют в обсуждении, используя программное обеспечение для чата, и Наоми решает открыть видеозвонок между ними. Вот ожидаемая последовательность событий:

Diagram of the signaling process

Мы более подробно рассмотрим это в ходе этой статьи.

Процесс обмена кандидатами ICE

Когда каждый узел ICE начинает отправлять кандидатов, он начинает обмен между различными точками в цепочке, который выглядит так:

Diagram of ICE candidate exchange process

Каждая сторона отправляет кандидатов другой стороне по мере их получения от своего локального слоя ICE; нет очередей или группировки кандидатов. Как только оба узла согласуются с одним кандидатом, который они оба могут использовать для обмена медиаданными, начинается передача медиаданных. Каждый узел продолжает отправлять кандидатов до тех пор, пока у него не закончатся варианты, даже после того, как медиаданные уже начали передаваться. Это делается в надежде на определение ещё лучших вариантов, чем изначально выбранные.

Если условия изменятся (например, подключение к сети ухудшится), один или оба узла могут предложить переключиться на медиарешение с меньшей пропускной способностью или на альтернативный кодек. Это запускает новый обмен кандидатами, после чего может произойти изменение другого формата медиа и/или кодека. В руководстве Используемые WebRTC кодеки вы можете узнать больше о кодеках, которые WebRTC требует поддержки браузерами, о дополнительных кодеках, поддерживаемых различными браузерами, и о том, как выбрать лучшие кодеки для использования.

Дополнительно, вы можете ознакомиться с RFC 8445: Установление интерактивного соединения, разделом 2.3 («Переговоры пар кандидатов и завершение ICE»), если вы хотите более глубоко понять, как этот процесс выполняется внутри слоя ICE. Обратите внимание, что кандидаты обмениваются и медиаданные начинают передаваться, как только слой ICE удовлетворен. Все это происходит в фоновом режиме. Наша роль — отправлять кандидатов туда и обратно через сервер сигнализации.

Приложение клиента

Ядром любого процесса сигнализации является обработка сообщений. Для сигнализации не обязательно использовать WebSockets, но это распространенное решение. Конечно, вам следует выбрать механизм обмена информацией о сигнализации, подходящий для вашего приложения.

Давайте обновим приложение чата для поддержки видеозвонков.

Обновление HTML

HTML для нашего клиента нуждается в месте для отображения видео. Это требует элементов видео и кнопки для завершения звонка:

<div class="flexChild" id="camera-container">
  <div class="camera-box">
    <video id="received_video" autoplay></video>
    <video id="local_video" autoplay muted></video>
    <button id="hangup-button" onclick="hangUpCall();" disabled>Hang Up</button>
  </div>
</div>

Определенная здесь структура страницы использует элементы <div>, что даёт нам полный контроль над макетом страницы, позволяя использовать CSS. В этом руководстве мы пропустим детали макета, но вы можете посмотреть CSS на GitHub, чтобы увидеть, как мы это сделали. Обратите внимание на два элемента <video>, один для просмотра себя, другой для связи, и элемент <button>.

Элемент <video> с атрибутами id received_video будет отображать видео, полученное от подключенного пользователя. Мы задаём атрибут autoplay, чтобы видео сразу воспроизводилось после его получения. Это исключает необходимость явного управления воспроизведением в нашем коде. Элемент local_video <video> отображает предварительный просмотр камеры пользователя; мы задаём атрибут muted, так как нам не нужен локальный звук в этом окне предварительного просмотра.

Наконец, кнопка hangup-button <button> для отключения от звонка определяется и настраивается как отключенная по умолчанию (когда нет подключения) и выполняет функцию hangUpCall() при нажатии. Эта функция закрывает звонок и отправляет уведомление на сервер сигнализации другому участнику, чтобы он также завершил соединение.

Код JavaScript

Разделим этот код на функциональные области для более удобного описания его работы. Основной блок кода находится в функции connect(): она открывает сервер WebSocket на порту 6503 и устанавливает обработчик для приема сообщений в формате JSON. Этот код в целом обрабатывает сообщения текстового чата, как и раньше.

Отправка сообщений на сервер сигнализации

В нашем коде мы вызываем sendToServer() для отправки сообщений на сервер сигнализации. Эта функция использует подключение WebSocket для своей работы:

function sendToServer(msg) {
  const msgJSON = JSON.stringify(msg);

  connection.send(msgJSON);
}

Объект сообщения, переданный в эту функцию, преобразуется в строку JSON с помощью вызова JSON.stringify(), затем мы вызываем функцию send() подключения WebSocket для передачи сообщения на сервер.

Интерфейс для начала вызова

Код, который обрабатывает сообщение "user-list", вызывает handleUserListMsg(). Здесь мы настраиваем обработчик для каждого подключенного пользователя в списке пользователей, отображаемом слева от панели чата. Эта функция получает объект сообщения, чье свойство users — массив строк, определяющих имена всех подключенных пользователей.

function handleUserListMsg(msg) {
  const listElem = document.querySelector(".user-list-box");

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  msg.users.forEach((username) => {
    const item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite, false);

    listElem.appendChild(item);
  });
}

После получения ссылки на <ul>, содержащий список имён пользователей, в переменную listElem, мы очищаем список, удаляя каждый из его дочерних элементов.

Примечание: Очевидно, было бы эффективнее обновлять список, добавляя и удаляя отдельных пользователей вместо перестроения всего списка каждый раз, когда он изменяется, но этого достаточно для целей данного примера.

Затем мы итерируемся по массиву имён пользователей, используя forEach(). Для каждого имени мы создаем новый элемент <li>, затем создаем новый текстовый узел, содержащий имя пользователя, используя createTextNode(). Этот текстовый узел добавляется как дочерний элемент элемента <li>. Далее, мы устанавливаем обработчик для события click на элементе списка, что при нажатии на имя пользователя вызывает наш метод invite(), который мы рассмотрим в следующем разделе.

Наконец, мы добавляем новый элемент в <ul>, который содержит все имена пользователей.

Начало вызова

Когда пользователь нажимает на имя пользователя, с которым он хочет связаться, вызывается функция invite() в качестве обработчика события click:

const mediaConstraints = {
  audio: true, // We want an audio track
  video: true, // And we want a video track
};

function invite(evt) {
  if (myPeerConnection) {
    alert("You can't start a call because you already have one open!");
  } else {
    const clickedUsername = evt.target.textContent;

    if (clickedUsername === myUsername) {
      alert(
        "I'm afraid I can't let you talk to yourself. That would be weird.",
      );
      return;
    }

    targetUsername = clickedUsername;
    createPeerConnection();

    navigator.mediaDevices
      .getUserMedia(mediaConstraints)
      .then((localStream) => {
        document.getElementById("local_video").srcObject = localStream;
        localStream
          .getTracks()
          .forEach((track) => myPeerConnection.addTrack(track, localStream));
      })
      .catch(handleGetUserMediaError);
  }
}

Это начинается с базовой проверки: пользователь уже подключен? Если уже есть соединение RTCPeerConnection, то вызов невозможен. Затем имя нажатого пользователя извлекается из свойства textContent целевого элемента, и мы проверяем, не пытается ли тот же пользователь начать вызов.

Затем мы копируем имя вызываемого пользователя в переменную targetUsername и вызываем createPeerConnection(), функцию, которая создаст и выполнит базовую настройку RTCPeerConnection.

После создания RTCPeerConnection мы запрашиваем доступ к камере и микрофону пользователя, вызывая MediaDevices.getUserMedia(), который доступен нам через свойство MediaDevices.getUserMedia. При успешном выполнении возвращенного промиса выполняется наш обработчик then. Он получает в качестве входных данных объект MediaStream, представляющий поток с аудио с микрофона пользователя и видео с веб-камеры.

Примечание: Мы можем ограничить набор разрешенных входных медиа до определенного устройства или набора устройств, вызвав navigator.mediaDevices.enumerateDevices() для получения списка устройств, отфильтровав полученный список в соответствии с нашими критериями, затем используя значения deviceId выбранных устройств в поле deviceId объекта mediaConstraints, переданного в getUserMedia(). На практике это редко, если вообще необходимо, поскольку большая часть этой работы выполняется за вас getUserMedia().

Мы подключаем входящий поток к локальному элементу предварительного просмотра <video>, установив свойство srcObject элемента. Поскольку элемент настроен на автоматическое воспроизведение входящего видео, поток начинает воспроизводиться в нашем локальном окне предварительного просмотра.

Затем мы итерируемся по дорожкам в потоке, вызывая addTrack(), чтобы добавить каждую дорожку к RTCPeerConnection. Хотя подключение еще не полностью установлено, вы можете начать отправлять данные, когда сочтёте это уместным. Полученные данные до завершения переговоров ICE могут использоваться для определения лучшего подхода к подключению, что способствует процессу переговоров.

Обратите внимание, что для нативных приложений, таких как приложение для телефона, вы не должны начинать отправлять данные до тех пор, пока подключение не будет принято с обеих сторон как минимум, чтобы избежать непреднамеренной отправки видео и/или аудиоданных, когда пользователь к этому не готов.

Как только медиа присоединится к RTCPeerConnection, событие negotiationneeded будет сгенерировано на подключении, чтобы начать переговоры ICE.

Если при попытке получить локальный медиапоток произойдёт ошибка, наш блок catch вызывает handleGetUserMediaError(), который отобразит соответствующую ошибку пользователю, как требуется.

Обработка ошибок getUserMedia()

Если возвращённое обещание getUserMedia() завершится ошибкой, выполняется наша функция handleGetUserMediaError().

function handleGetUserMediaError(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;
  }

  closeVideoCall();
}

Сообщение об ошибке отображается во всех случаях, кроме одного. В этом примере мы игнорируем "SecurityError" и "PermissionDeniedError" результаты, рассматривая отказ в предоставлении разрешения на использование медиа-оборудования так же, как и отмену вызова пользователем.

Независимо от причины неудачи попытки получения потока, мы вызываем нашу функцию closeVideoCall() для закрытия RTCPeerConnection и освобождения всех ресурсов, уже выделенных в процессе попытки вызова. Этот код предназначен для безопасной обработки частично начатых вызовов.

Создание подключения peer

Функция createPeerConnection() используется как звонящим, так и вызываемым для построения своих объектов RTCPeerConnection, своих соответствующих концов подключения WebRTC. Она вызывается invite() при попытке звонящего начать вызов и handleVideoOfferMsg() при получении вызываемым сообщением о предложении от звонящего.

function createPeerConnection() {
  myPeerConnection = new RTCPeerConnection({
    iceServers: [
      // Information about ICE servers - Use your own!
      {
        urls: "stun:stun.stunprotocol.org",
      },
    ],
  });

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.ontrack = handleTrackEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.onremovetrack = handleRemoveTrackEvent;
  myPeerConnection.oniceconnectionstatechange =
    handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange =
    handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
}

При использовании конструктора RTCPeerConnection() мы укажем объект, предоставляющий параметры конфигурации для подключения. В этом примере мы используем только один из них: iceServers. Это массив объектов, описывающих серверы STUN и/или TURN для слоя ICE, которые будут использоваться при попытке установить маршрут между звонящим и вызываемым. Эти серверы используются для определения лучшего маршрута и протоколов для использования при общении между пользователями, даже если они находятся за брандмауэром или используют NAT.

Примечание: Вы всегда должны использовать серверы STUN/TURN, которыми вы владеете, или которые вы имеете право использовать. В этом примере используется известный публичный STUN-сервер, но злоупотребление ими нежелательно.

Каждый объект в iceServers содержит по крайней мере поле urls, предоставляющее URL-адреса, по которым можно обратиться к указанному серверу. Он также может предоставлять значения username и credential, чтобы разрешить аутентификацию при необходимости.

После создания RTCPeerConnection мы настраиваем обработчики событий, которые нам важны.

Первые три из этих обработчиков событий обязательны; вы должны их обработать, чтобы выполнить какие-либо действия, связанные с потоковым воспроизведением медиа с помощью WebRTC. Остальные не строго обязательны, но могут быть полезны, и мы их рассмотрим. Есть еще несколько доступных событий, которыми мы не пользуемся в этом примере. Вот краткое описание каждого из обработчиков событий, которые мы будем реализовывать:

onicecandidate

Местный модуль ICE вызывает обработчик события icecandidate, когда ему нужно передать кандидатуру ICE другому участнику через ваш сервер сигнализации. Дополнительную информацию и пример кода см. в разделе Отправка кандидатур ICE.

ontrack

Обработчик события track вызывается локальным слоем WebRTC, когда в соединение добавляется поток. Это позволяет вам подключить входящий медиапоток к элементу для его отображения, например. Подробнее см. в разделе Получение новых потоков.

onnegotiationneeded

Эта функция вызывается всякий раз, когда инфраструктура WebRTC требует от вас начать процесс новой переговорной сессии. Она должна создать и отправить предложение (offer) вызываемому абоненту, попросив его подключиться к нам. См. Начало переговоров, чтобы увидеть, как мы обрабатываем это.

onremovetrack

Этот аналог ontrack вызывается для обработки события removetrack, которое отправляется RTCPeerConnection, когда удалённый участник удаляет поток из отправляемого медиа. См. Обработка удаления потоков.

oniceconnectionstatechange

Событие iceconnectionstatechange отправляется слоем ICE, чтобы сообщить вам об изменениях в состоянии соединения ICE. Это может помочь вам узнать, когда соединение прервалось или было потеряно. Пример кода см. в разделе Состояние подключения ICE ниже.

onicegatheringstatechange

Модуль ICE отправляет вам событие icegatheringstatechange, когда процесс сбора кандидатов агента ICE меняет состояние (например, начинает сбор кандидатов или завершает переговоры). См. раздел Состояние сбора ICE ниже.

onsignalingstatechange

Инфраструктура WebRTC отправляет вам сообщение signalingstatechange, когда изменяется состояние процесса сигнализации (или при изменении подключения к серверу сигнализации). См. Состояние сигнализации для просмотра нашего кода.

Начало переговоров

После того, как вызывающий создал свой RTCPeerConnection, создал медиапоток и добавил его потоки в соединение, как показано в Начало звонка, браузер отправит событие negotiationneeded в RTCPeerConnection, чтобы указать, что он готов начать переговоры с другим участником. Вот наш код для обработки события negotiationneeded:

function handleNegotiationNeededEvent() {
  myPeerConnection
    .createOffer()
    .then((offer) => myPeerConnection.setLocalDescription(offer))
    .then(() => {
      sendToServer({
        name: myUsername,
        target: targetUsername,
        type: "video-offer",
        sdp: myPeerConnection.localDescription,
      });
    })
    .catch(window.reportError);
}

Чтобы начать процесс переговоров, нам нужно создать и отправить предложение SDP (offer) участнику, с которым мы хотим подключиться. Это предложение содержит список поддерживаемых настроек для подключения, включая информацию о медиапотоке, который мы добавили в подключение локально (то есть видео, которое мы хотим отправить на другой конец вызова), и любые кандидаты ICE, собранные слоем ICE. Мы создаем это предложение, вызывая myPeerConnection.createOffer().

Когда createOffer() выполняется (выполняет обещание), мы передаем созданную информацию об предложении в myPeerConnection.setLocalDescription(), которое настраивает состояние подключения и медиаконфигурации для стороны вызывающего.

Примечание: Технически говоря, строка, возвращаемая createOffer() — это предложение RFC 3264.

Мы знаем, что описание является допустимым и было установлено, когда обещание, возвращенное setLocalDescription(), выполнено. Именно в этот момент мы отправляем наше предложение другому участнику, создавая новое сообщение "video-offer", содержащее локальное описание (теперь оно такое же, как предложение), а затем отправляя его через наш сервер сигнализации вызываемому абоненту. Предложение имеет следующие члены:

type

Тип сообщения: "video-offer".

name

Имя пользователя звонящего.

target

Имя пользователя, которому мы хотим позвонить.

sdp

Строка SDP, описывающая предложение.

Если возникает ошибка, как при первоначальном createOffer() , так и в любом из последующих обработчиков выполнения, ошибка сообщается путём вызова нашей функции window.reportError().

После того, как обработчик выполнения setLocalDescription() был выполнен, агент ICE начинает отправлять события icecandidate в RTCPeerConnection, по одному на каждую потенциальную конфигурацию, которую он обнаруживает. Наш обработчик события icecandidate отвечает за передачу кандидатов другому пользователю.

Переговоры о сессии

Теперь, когда мы начали переговоры с другим пользователем и передали предложение, давайте посмотрим, что происходит на стороне получателя в течение некоторого времени. Получатель получает предложение и вызывает функцию handleVideoOfferMsg() для его обработки. Давайте посмотрим, как получатель обрабатывает сообщение "video-offer".

Обработка приглашения

При получении предложения вызывается функция handleVideoOfferMsg() получателя с полученным сообщением "video-offer". Эта функция должна выполнить два действия. Во-первых, она должна создать свой RTCPeerConnection и добавить в него дорожки, содержащие аудио и видео с микрофона и веб-камеры. Во-вторых, она должна обработать полученное предложение, сконструировать и отправить свой ответ.

function handleVideoOfferMsg(msg) {
  let localStream = null;

  targetUsername = msg.name;
  createPeerConnection();

  const desc = new RTCSessionDescription(msg.sdp);

  myPeerConnection
    .setRemoteDescription(desc)
    .then(() => navigator.mediaDevices.getUserMedia(mediaConstraints))
    .then((stream) => {
      localStream = stream;
      document.getElementById("local_video").srcObject = localStream;

      localStream
        .getTracks()
        .forEach((track) => myPeerConnection.addTrack(track, localStream));
    })
    .then(() => myPeerConnection.createAnswer())
    .then((answer) => myPeerConnection.setLocalDescription(answer))
    .then(() => {
      const msg = {
        name: myUsername,
        target: targetUsername,
        type: "video-answer",
        sdp: myPeerConnection.localDescription,
      };

      sendToServer(msg);
    })
    .catch(handleGetUserMediaError);
}

Этот код очень похож на то, что мы сделали в функции invite() в разделе Начало вызова. Он начинается с создания и настройки RTCPeerConnection с помощью нашей функции createPeerConnection(). Затем он получает SDP-предложение из полученного сообщения "video-offer" и использует его для создания нового объекта RTCSessionDescription, представляющего описание сессии вызывающего абонента.

Затем это описание сессии передаётся в myPeerConnection.setRemoteDescription(). Это устанавливает полученное предложение как описание удалённого (вызывающего) конца соединения. Если это успешно, обработчик выполнения обещания (в блоке then() ) запускает процесс получения доступа к камере и микрофону получателя с помощью getUserMedia(), добавления дорожек в соединение и так далее, как мы видели ранее в invite().

После создания ответа с помощью myPeerConnection.createAnswer(), описание локального конца соединения устанавливается в SDP ответа путём вызова myPeerConnection.setLocalDescription(), а затем ответ передаётся через сервер сигнализации звонящему, чтобы сообщить ему об ответе.

Любые ошибки ловятся и передаются в handleGetUserMediaError(), описанные в Обработка ошибок getUserMedia().

Примечание: Как и в случае с вызывающим абонентом, после выполнения обработчика выполнения setLocalDescription() браузер начинает генерировать события icecandidate, которые должен обработать получатель, по одному на каждого кандидата, который необходимо передать удалённому пользователю.

Наконец, вызывающий абонент обрабатывает полученное сообщение об ответе, создавая новый объект RTCSessionDescription, представляющий описание сессии получателя, и передавая его в myPeerConnection.setRemoteDescription().

function handleVideoAnswerMsg(msg) {
  const desc = new RTCSessionDescription(msg.sdp);
  myPeerConnection.setRemoteDescription(desc).catch(window.reportError);
}
Отправка ICE-кандидатов

Процесс ICE-переговоров подразумевает, что каждый абонент отправляет кандидатов другому, многократно, пока не исчерпает все возможные способы удовлетворения потребностей в передаче медиа RTCPeerConnection. Поскольку ICE не знает о вашем сервере сигнализации, ваш код обрабатывает передачу каждого кандидата в обработчике события icecandidate.

Ваш обработчик onicecandidate получает событие, у которого свойство candidate является SDP, описывающим кандидата (или является null , чтобы указать, что уровень ICE исчерпал все потенциальные конфигурации для предложения). Содержание candidate — это то, что вам нужно передать, используя ваш сервер сигнализации. Вот реализация нашего примера:

function handleICECandidateEvent(event) {
  if (event.candidate) {
    sendToServer({
      type: "new-ice-candidate",
      target: targetUsername,
      candidate: event.candidate,
    });
  }
}

Это создаёт объект, содержащий кандидата, а затем отправляет его другому пользователю с помощью функции sendToServer() , ранее описанной в Отправка сообщений на сервер сигнализации. Свойства сообщения:

type

Тип сообщения: "new-ice-candidate".

target

Имя пользователя, которому должен быть доставлен ICE-кандидат. Это позволяет серверу сигнализации маршрутизировать сообщение.

candidate

SDP, представляющий кандидата, который ICE-слой хочет передать другому участнику.

Формат этого сообщения (как и во всех операциях по обработке сигнализации) полностью зависит от ваших потребностей; вы можете предоставить другую необходимую информацию.

Примечание: Важно помнить, что событие icecandidate не отправляется, когда ICE-кандидаты поступают с другого конца вызова. Вместо этого они отправляются вашим концом вызова, чтобы вы могли самостоятельно передать данные по выбранному каналу. Это может быть запутанно для начинающих с WebRTC.

Получение ICE-кандидатов

Сервер сигнализации доставляет каждый ICE-кандидат целевому участнику выбранным способом; в нашем примере это JSON-объекты со свойством type, содержащим строку "new-ice-candidate". Наша функция handleNewICECandidateMsg() вызывается кодом обработки входящих сообщений нашего основного WebSocket для обработки этих сообщений:

function handleNewICECandidateMsg(msg) {
  const candidate = new RTCIceCandidate(msg.candidate);

  myPeerConnection.addIceCandidate(candidate).catch(window.reportError);
}

Эта функция создаёт объект RTCIceCandidate, передавая полученный SDP в его конструктор, затем передает кандидата в ICE-слой, передавая его в myPeerConnection.addIceCandidate(). Это передаёт свежий ICE-кандидат локальному ICE-слою, и наша роль в процессе обработки этого кандидата завершается.

Каждый участник отправляет другому участнику кандидата для каждой возможной конфигурации транспорта, который он считает подходящим для обмена медиа. В какой-то момент оба участника соглашаются, что данный кандидат является хорошим выбором, и они открывают соединение и начинают обмен медиа. Однако важно отметить, что ICE-переговоры не прекращаются после начала потока медиа. Вместо этого кандидаты могут по-прежнему обмениваться после начала разговора, либо для поиска лучшего метода подключения, либо потому, что они уже находились в процессе транспорта, когда участники успешно установили соединение.

Кроме того, если что-то произойдёт, что вызовет изменения в сценарии потоковой передачи, начнется переговорный процесс, и событие negotiationneeded будет отправлено в RTCPeerConnection, и весь процесс начнется заново, как описано ранее. Это может произойти в различных ситуациях, включая:

  • Изменения в состоянии сети, такие как изменение пропускной способности, переход с Wi-Fi на сотовую связь и т.п.
  • Переключение между передней и задней камерами на телефоне.
  • Изменение конфигурации потока, например, его разрешения или частоты кадров.
Получение новых потоков

Когда к RTCPeerConnection добавляются новые треки — либо путем вызова метода addTrack(), либо из-за переподключения формата потока — событие track устанавливается для RTCPeerConnection для каждого добавленного в соединение трека. Для использования вновь добавленных медиа требуется реализация обработчика события track. Общей задачей является привязка входящих медиа к соответствующему элементу HTML. В нашем примере мы добавляем поток трека в элемент <video>, который отображает входящее видео:

function handleTrackEvent(event) {
  document.getElementById("received_video").srcObject = event.streams[0];
  document.getElementById("hangup-button").disabled = false;
}

Входящий поток прикрепляется к элементу "received_video" <video>, а элемент "Завершить" <button> активируется, чтобы пользователь мог завершить вызов.

После выполнения этого кода видео, отправляемое другим участником, наконец, отображается в окне браузера!

Обработка удаления треков

Ваш код получает событие removetrack, когда удалённый участник удаляет трек из соединения, вызвав RTCPeerConnection.removeTrack(). Наш обработчик для "removetrack" выглядит следующим образом:

function handleRemoveTrackEvent(event) {
  const stream = document.getElementById("received_video").srcObject;
  const trackList = stream.getTracks();

  if (trackList.length === 0) {
    closeVideoCall();
  }
}

Этот код извлекает входящее видео MediaStream из свойства srcObject элемента "received_video" <video>, затем вызывает метод потока getTracks() для получения массива треков потока.

Если длина массива равна нулю, то есть в потоке не осталось треков, мы завершаем вызов, вызвав closeVideoCall(). Это аккуратно восстанавливает наше приложение в состояние готовности к запуску или получению другого вызова. См. Завершение вызова, чтобы узнать, как closeVideoCall() работает.

Завершение вызова

Существует множество причин, по которым вызовы могут завершаться. Вызов может быть завершён, при этом одна или обе стороны повесили трубку. Возможно, произошла ошибка сети, или один из пользователей закрыл свой браузер или у него произошла системная ошибка. В любом случае, всё хорошее должно когда-нибудь закончиться.

Прерывание вызова

Когда пользователь нажимает кнопку «Повесить трубку», чтобы завершить вызов, вызывается функция hangUpCall():

function hangUpCall() {
  closeVideoCall();
  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "hang-up",
  });
}

hangUpCall() выполняет closeVideoCall(), чтобы остановить и сбросить соединение, а также освободить ресурсы. Затем она создаёт сообщение "hang-up" и отправляет его на другой конец вызова, чтобы сообщить другому участнику о корректном завершении вызова.

Завершение вызова

Функция closeVideoCall(), показанная ниже, отвечает за остановку потоков, очистку и удаление объекта RTCPeerConnection:

function closeVideoCall() {
  const remoteVideo = document.getElementById("received_video");
  const localVideo = document.getElementById("local_video");

  if (myPeerConnection) {
    myPeerConnection.ontrack = null;
    myPeerConnection.onremovetrack = null;
    myPeerConnection.onremovestream = null;
    myPeerConnection.onicecandidate = null;
    myPeerConnection.oniceconnectionstatechange = null;
    myPeerConnection.onsignalingstatechange = null;
    myPeerConnection.onicegatheringstatechange = null;
    myPeerConnection.onnegotiationneeded = null;

    if (remoteVideo.srcObject) {
      remoteVideo.srcObject.getTracks().forEach((track) => track.stop());
    }

    if (localVideo.srcObject) {
      localVideo.srcObject.getTracks().forEach((track) => track.stop());
    }

    myPeerConnection.close();
    myPeerConnection = null;
  }

  remoteVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
  localVideo.removeAttribute("src");
  localVideo.removeAttribute("srcObject");

  document.getElementById("hangup-button").disabled = true;
  targetUsername = null;
}

После получения ссылок на два элемента <video>, мы проверяем, существует ли подключение WebRTC; если оно существует, мы переходим к отключению и закрытию вызова:

  1. Все обработчики событий удаляются. Это предотвращает срабатывание случайных обработчиков событий во время закрытия соединения, что потенциально может привести к ошибкам.
  2. Для удалённого и локального видеопотоков мы перебираем каждый трек, вызывая метод MediaStreamTrack.stop() для закрытия каждого из них.
  3. Закройте подключение RTCPeerConnection, вызвав myPeerConnection.close().
  4. Установите myPeerConnection в значение null, убедившись, что наш код понимает, что текущий вызов отсутствует; это полезно, когда пользователь щелкает имя в списке пользователей.

Затем для элементов <video> входного и выходного потоков мы удаляем их свойства src и srcObject с помощью методов removeAttribute(). Это завершает отсоединение потоков от элементов видео.

Наконец, мы устанавливаем свойство disabled для кнопки «Повесить трубку» в значение true, делая её некликабельной во время отсутствия активного вызова; затем мы устанавливаем targetUsername в значение null, так как мы больше ни с кем не говорим. Это позволяет пользователю совершить другой звонок или принять входящий вызов.

Обработка изменений состояния

Существует ряд дополнительных событий, для которых вы можете установить обработчики, чтобы уведомить ваш код о различных изменениях состояния. Мы используем три из них: iceconnectionstatechange, icegatheringstatechange и signalingstatechange.

Состояние подключения ICE

События iceconnectionstatechange отправляются слою ICE в RTCPeerConnection при изменении состояния подключения (например, при завершении вызова на другом конце).

function handleICEConnectionStateChangeEvent(event) {
  switch (myPeerConnection.iceConnectionState) {
    case "closed":
    case "failed":
      closeVideoCall();
      break;
  }
}

Здесь мы применяем нашу функцию closeVideoCall(), когда состояние подключения ICE изменяется на "closed" или "failed". Это обрабатывает закрытие нашего конца соединения, чтобы мы были готовы начать или принять вызов снова.

Примечание: Мы не отслеживаем состояние сигнализации disconnected здесь, так как оно может указывать на временные проблемы и может вернуться в состояние connected через некоторое время. Отслеживание его привело бы к закрытию видеозвонка при любой временной проблеме с сетью.

Состояние сигнализации ICE

Аналогично, мы отслеживаем события signalingstatechange. Если состояние сигнализации изменяется на closed, мы также закрываем вызов.

function handleSignalingStateChangeEvent(event) {
  switch (myPeerConnection.signalingState) {
    case "closed":
      closeVideoCall();
      break;
  }
}

Примечание: Состояние сигнализации closed устарело в пользу состояния closed iceConnectionState. Мы отслеживаем его здесь, чтобы добавить некоторую обратную совместимость.

Состояние сбора ICE

События icegatheringstatechange используются для информирования вас о том, когда меняется состояние процесса сбора кандидатов ICE. В нашем примере это не используется, но это может быть полезно для отладки, а также для обнаружения завершения сбора кандидатов.

function handleICEGatheringStateChangeEvent(event) {
  // Our sample just logs information to console here,
  // but you can do whatever you need.
}

Следующие шаги

Теперь вы можете попробовать этот пример на Glitch, чтобы увидеть его в действии. Откройте консоль браузера на обоих устройствах и посмотрите на выводимые данные — хотя вы этого не видите в коде, как показано выше, код на сервере (и на GitHub) содержит много выводимых в консоль данных, поэтому вы можете увидеть процессы сигнализации и подключения в работе.

Другим очевидным улучшением было бы добавление функции «звонка», чтобы вместо простого запроса разрешения на использование камеры и микрофона сначала отображалось сообщение «Пользователь X звонит. Вы хотите ответить?».

См. также

  • API WebRTC
  • Веб-медиатехнологии
  • Руководство по типам и форматам медиа в сети
  • API захвата медиа и потоков
  • API возможностей медиа
  • API записи медиапотоков
  • Шаблон идеального согласования

© 2005–2024 MDN contributors.
Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API