React: WebRTC Media Call

от автора

Привет, друзья!

В этой статье я покажу вам, как разработать приложение для совершения аудио/видео звонков с помощью WebRTC.

Функционал нашего приложения будет следующим:

  • при запуске приложения пользователь А получает уникальный идентификатор;
  • он передает этот идентификатор пользователю Б;
  • пользователь Б использует идентификатор пользователя А для совершения аудио или видео звонка;
  • пользователь А получает уведомление о звонке пользователя Б и может ответить на него с видео или без либо отклонить звонок;
  • в процессе соединения пользователи имеют возможность включать/выключать аудио и видео;
  • после завершения звонка выполняется перезагрузка WebRTC для обеспечения возможности совершения нового звонка.

Демо приложения.

Репозиторий с исходным кодом.

Основной источник вдохновения.

Если вам это интересно, прошу под кат.

Для установки зависимостей мы будем использовать Yarn.

Для создания шаблона приложения — Vite.

Для стилизации — Sass.

Для инициализации медиасессии (signalling — сигнализации) между браузерами — веб-сокеты в лице Socket.io.

Основные спецификации, на которые мы будем опираться при разработке приложения:

  • WebRTC 1.0: Real-Time Communication Between Browsers — набор API для обмена медиа и другими данными между браузерами, в которых реализован соответствующий набор протоколов, в режиме реального времени;
  • An Offer/Answer Model with the Session Description Protocol (SDP) — механизм, позволяющий браузерам устанавливать соединение с помощью протокола описания сессии (Session Description Protocol, SDP);
  • Media Capture and Streams — набор API, позволяющих браузеру получать доступ к медиапотоку с устройств пользователя.

Ссылки на соответствующие разделы MDN будут приводиться по мере необходимости.

Фактически наше приложение будет продвинутой реализацией примеров, описанных здесь и здесь.

Подготовка и настройка проекта

Создаем директорию, переходим в нее и создаем шаблон React-приложения:

mkdir react-webrtc cd react-webrtc  yarn create vite client --template react

Пока создается шаблон, поговорим о процессе установки P2P-соединения (peer-to-peer — равный к равному, одноранговая сеть), об интерфейсах и методах, задействованных в этом процессе.

Вот что происходит на самом высоком уровне:

  • браузер пользователя А (далее — А) захватывает (capture) медиапоток с устройств пользователя (видеокамера и микрофон). Для этого используется метод getUserMedia:

// ограничения или требования к потоку // https://w3c.github.io/mediacapture-main/#constrainable-interface const config = {  audio: true,  video: true }  // https://w3c.github.io/mediacapture-main/#dom-mediadevices-getusermedia const localStream = await navigator.mediaDevices.getUserMedia(config)  // медиапоток состоит из 1 или более медиатреков // https://w3c.github.io/mediacapture-main/#dom-mediastream-gettracks const localTracks = localStream.getTracks()

// https://w3c.github.io/webrtc-pc/#dom-rtcconfiguration const config = { iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] }  // https://w3c.github.io/webrtc-pc/#interface-definition const pc = new RTCPeerConnection(config)

  • А добавляет захваченные треки и поток в экземпляр RTCPeerConnection с помощью метода addTrack:

stream.getTracks().forEach((track) => {  // https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-addtrack  pc.addTrack(track, stream) })

  • А генерирует предложение (offer) об установке соединения с помощью метода createOffer. Данный метод возвращает RTCLocalSessionDescriptionInit:

// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-createoffer const offer = await pc.createOffer()

  • А вызывает метод setLocalDescription, передавая ему предложение:

// https://w3c.github.io/webrtc-pc/#dom-peerconnection-setlocaldescription pc.setLocalDescription(offer)

  • предложение передается браузеру пользователя Б (далее — Б) любым доступным способом, например, через веб-сокеты (сигнализация):

socket.emit('call', {  // идентификатор Б  to: remoteId,  // предложение  sdp: offer })

// https://w3c.github.io/webrtc-pc/#rtcsessiondescription-class const sdp = new RTCSessionDescription(desc) // https://w3c.github.io/webrtc-pc/#dom-peerconnection-setremotedescription pc.setRemoteDescription(sdp)

  • при добавлении А треков и потока в экземпляр RTCPeerConnection на стороне Б возникает событие track, которое обрабатывается с помощью ontrack:

// событие содержит медиапотоки, полученные от другой стороны, в виде массива // в нашем случае в массиве будет только один такой поток // https://w3c.github.io/webrtc-pc/#rtctrackevent // https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-ontrack pc.ontrack = ({ streams }) => {  remoteStream = streams[0] }

  • Б захватывает медиапоток с устройств пользователя, добавляет треки и поток в экземпляр RTCPeerConnection, генерирует ответ на предложение об установке соединения (answer) с помощью метода createAnswer, вызывает setLocalDescription с ответом и передает ответ А:

// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-createanswer const answer = await pc.createAnswer()  pc.setLocalDescription(answer)  // сигнализация socket.emit('call', {  // идентификатор А  to: remoteId,  sdp: answer })

  • А, в свою очередь, также вызывает setRemoteDescription и регистрирует ontrack;

  • в это же время (после вызова setLocalDescription) происходит подбор кандидатов для установки интерактивного соединения (ICE gathering). Возникает событие icecandidate, которое обрабатывается с помощью onicecandidate:

// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-onicecandidate // https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnectioniceevent pc.onicecandidate = ({ candidate }) => {  // событие содержит `RTCIceCandidateInit`  // https://w3c.github.io/webrtc-pc/#dom-rtcicecandidateinit  // передаем "кандидата" другой стороне  socket.emit('call', {    to: remoteId,    candidate  }) }

// https://w3c.github.io/webrtc-pc/#rtcicecandidate-interface const _candidate = new RTCIceCandidate(candidate)  // https://w3c.github.io/webrtc-pc/#dom-peerconnection-addicecandidate pc.addIceCandidate(_candidate)

  • после этого стороны могут напрямую обмениваться медиаданными.

Более подробную информацию о том, как все это происходит на более низком уровне, в доступной форме можно получить здесь.

Сервер

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

Создаем директорию, переходим в нее, инициализируем Node.js-проект и устанавливаем зависимости:

mkdir server cd server  yarn init -yp  yarn add express socket.io dotenv nanoid  yarn add -D nodemon

Зависимости:

  • expressNode.js-фреймворк для разработки веб-серверов;
  • socket.io — библиотека, облегчающая работу с веб-сокетами;
  • dotenv — утилита для работы с переменными среды окружения;
  • nanoid — утилита для генерации идентификаторов;
  • nodemon — утилита для запуска сервера для разработки.

Определяем тип кода сервера и команды для запуска серверов в package.json:

"type": "module", "scripts": {  "dev": "nodemon",  "start": "node index.js" }

Создаем файл index.js:

touch index.js

Импортируем библиотеки и утилиты:

import express from 'express' import { createServer } from 'http' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { Server } from 'socket.io' import { config } from 'dotenv' import initSocket from './utils/initSocket.js'

Получаем доступ к переменным среды окружения и формируем путь к текущей директории:

config()  const __dirname = dirname(fileURLToPath(import.meta.url))

Создаем экземпляры express и сервера:

const app = express() const server = createServer(app)

Добавляем обслуживание статических файлов из сборки клиента:

app.use(express.static(join(__dirname, '../client/dist')))

Создаем экземпляр socket.io и добавляем обработку подключения:

const io = new Server(server, {  cors: process.env.ALLOWED_ORIGIN,  serveClient: false }) io.on('connection', initSocket)

Определяем порт и запускаем сервер:

const port = process.env.PORT || 4000 server.listen(port, () => {  console.log(`Server ready on port ${port} ?`) })

Создаем файл .env и записываем в него разрешенный источник:

ALLOWED_ORIGIN=http://localhost:3000

Реализуем обработку подключения (utils/initSocket.js).

Импортируем nanoid и определяем переменную для пользователей:

import { nanoid } from 'nanoid'  const users = {}  // функция принимает сокет export default function initSocket(socket) {  // TODO }

Сокет будет регистрировать и обрабатывать 5 типов событий:

  • init: установка соединения — генерация id пользователя, сохранение пользователя (его сокета) и передача id клиенту;
  • request: передача id пользователя другому клиенту;
  • call: начало звонка;
  • end: завершение звонка;
  • disconnect: разрыв соединения (отключение сокета).

Определяем переменную для id пользователя и вспомогательную функцию для передачи данных адресату:

let id  // функция принимает `id` адресата, тип события и полезную нагрузку - данные для передачи const emit = (userId, event, data) => {  // определяем получателя  const receiver = users[userId]  if (receiver) {    // вызываем событие    receiver.emit(event, data)  } }

Обрабатываем названные выше события:

socket  .on('init', () => {    id = nanoid(5)    users[id] = socket    console.log(id, 'connected')    socket.emit('init', { id })  })  .on('request', (data) => {    emit(data.to, 'request', { from: id })  })  .on('call', (data) => {    emit(data.to, 'call', { ...data, from: id })  })  .on('end', (data) => {    emit(data.to, 'end')  })  .on('disconnect', () => {    delete users[id]    console.log(id, 'disconnected')  })

Думаю, здесь все понятно.

Больше от нашего сервера ничего не требуется.

Клиент

Переходим в директорию с кодом клиента и устанавливаем 3 дополнительные зависимости:

cd client  yarn add socket.io-client sass react-icons

Структура директории src будет следующей:

  • components
    • MainWindow.jsx — начальный экран
    • CallModal.jsx — модальное окно с уведомлением о входящем звонке
    • CallWindow.jsx — экран для коммуникации
    • index.js — повторный экспорт компонентов
  • styles — стили (я не буду о них рассказывать, просто скопируйте их из репозитория с исходным кодом)
  • utils
    • socket.js — инициализация socket.io
    • Emitter.js — интерфейс — реализация паттерна Pub/Sub
    • MediaDevices.js — интерфейс для работы с медиапотоком
    • PeerConnection.js — интерфейс для работы с RTCPeerConnection
  • App.jsx
  • main.jsx

Начнем с утилиты и интерфейсов.

Интерфейсы и утилита

Инициализируем сокет (utils/socket.js):

import { io } from 'socket.io-client'  // в производственном режиме сервер и клиент будут находиться в одном источнике (origin), // а в режиме для разработки - в разных const SERVER_URI = import.meta.env.DEV ? 'http://localhost:4000' : ''  const socket = io(SERVER_URI)  export default socket

Определяем интерфейс pub/sub (utils/Emitter.js):

class Emitter {  constructor() {    this.events = {}  }   emit(e, ...args) {    if (this.events[e]) {      this.events[e].forEach((fn) => fn(...args))    }    return this  }   on(e, fn) {    this.events[e] ? this.events[e].push(fn) : (this.events[e] = [fn])    return this  }   off(e, fn) {    if (e && typeof fn === 'function') {      const listeners = this.events[e]      listeners.splice(        listeners.findIndex((_fn) => _fn === fn),        1      )    } else {      this.events[e] = []    }    return this  } }  export default Emitter

Данный интерфейс предоставляет 3 метода:

  • emit: для запуска обработчиков определенного события;
  • on: для подписки — добавления обработчиков определенного события;
  • off: для отписки — удаления конкретного или всех обработчиков определенного события.

Обратите внимание: каждый метод возвращает this. Это позволяет вызывать методы интерфейса в цепочке, например, emitter.on(event1, callback1).on(event2, callback2).

Интерфейсы MediaDevices и PeerConnection будут расширять интерфейс Emitter, тем самым наследуя указанные методы.

Рассмотрим интерфейс для работы с медиапотоком (utils/MediaDevices.js):

import Emitter from './Emitter'  class MediaDevice extends Emitter {  start() {    navigator.mediaDevices      .getUserMedia({        audio: true,        video: true      })      .then((stream) => {        this.stream = stream        this.emit('stream', stream)      })      .catch(console.error)     return this  }   toggle(type, on) {    if (this.stream) {      this.stream[`get${type}Tracks`]().forEach((t) => {        t.enabled = on ? on : !t.enabled      })    }     return this  }   stop() {    if (this.stream) {      this.stream.getTracks().forEach((t) => { t.stop() })    }    // удаляем все обработчики всех событий    this.off()     return this  } }  export default MediaDevice

Данный интерфейс расширяет интерфейс Emitter и предоставляет 3 дополнительных метода:

  • start: для захвата медиапотока с устройств пользователя, его записи в переменную и вызова события stream;
  • toggle: для переключения состояния аудио и видео треков. Для получения аудио треков используется метод getAudioTracks, а для получения видеотреков — getVideoTracks;
  • stop: для остановки захвата медиапотока.

На интерфейсе PeerConnection остановимся подробнее.

Данный интерфейс расширяет интерфейс Emitter, использует интерфейс MediaDevices и утилиту socket:

import Emitter from './Emitter' import MediaDevice from './MediaDevice' import socket from './socket'  // настройки const CONFIG = { iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] }  class PeerConnection extends Emitter {  // TODO }

При создании экземпляра PeerConnection в его конструктор передается id адресата — того, кому мы звоним. В конструкторе id адресата записывается в переменную, создается экземпляр RTCPeerConnection, регистрируются обработчики событий icecandidate и track, создается экземпляр MediaDevices и выполняется привязка (фиксируется контекст для) метода getDescription:

constructor(remoteId) {  super()  this.remoteId = remoteId   this.pc = new RTCPeerConnection(CONFIG)  this.pc.onicecandidate = ({ candidate }) => {    socket.emit('call', {      to: this.remoteId,      candidate    })  }  this.pc.ontrack = ({ streams }) => {    // унаследованный метод    this.emit('remoteStream', streams[0])  }   this.mediaDevice = new MediaDevice()  this.getDescription = this.getDescription.bind(this) }

Метод для совершения звонка:

// метод принимает индикатор того, является ли пользователь инициатором звонка // и объект с настройками для `getUserMedia` start(isCaller, config) {  this.mediaDevice    // обрабатываем событие `stream`    .on('stream', (stream) => {      // добавляем захваченные треки и поток в `PeerConnection`      stream.getTracks().forEach((t) => {        this.pc.addTrack(t, stream)      })       // данный захваченный поток является локальным      this.emit('localStream', stream)       // если пользователь является инициатором звонка,      // отправляем запрос на звонок другой стороне,      // иначе генерируем предложение об установке соединения      isCaller        ? socket.emit('request', { to: this.remoteId })        : this.createOffer()    })    // запускаем метод `start` интерфейса `MediaDevices`    .start(config)   return this }

Метод для завершения звонка:

stop(isCaller) {  // если пользователь является инициатором завершения звонка,  // сообщаем о завершении другой стороне  if (isCaller) {    socket.emit('end', { to: this.remoteId })  }  // останавливаем захват медиапотока  this.mediaDevice.stop()  // перезагружаем систему для обеспечения возможности совершения нового звонка  this.pc.restartIce()  this.off()   return this }

Далее следует несколько вспомогательных функций, необходимых для начала медиасессии:

// метод для генерации предложения createOffer() {  this.pc.createOffer().then(this.getDescription).catch(console.error)   return this }  // метод для генерации ответа createAnswer() {  this.pc.createAnswer().then(this.getDescription).catch(console.error)   return this }  // метод для добавления локального описания в `PeerConnection` // и отправки описания другой стороне getDescription(desc) {  this.pc.setLocalDescription(desc)   socket.emit('call', { to: this.remoteId, sdp: desc })   return this }  // метод для добавления удаленного (в значении "находящегося далеко") описания в `PeerConnection` setRemoteDescription(desc) {  this.pc.setRemoteDescription(new RTCSessionDescription(desc))   return this }  // метод для добавления кандидата в `PeerConnection` addIceCandidate(candidate) {  // кандидат может быть пустой строкой  if (candidate) {    this.pc.addIceCandidate(new RTCIceCandidate(candidate))  }   return this }

Полный код рассматриваемого интерфейса:

import Emitter from './Emitter' import MediaDevice from './MediaDevice' import socket from './socket'  const CONFIG = { iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] }  class PeerConnection extends Emitter {  constructor(remoteId) {    super()    this.remoteId = remoteId     this.pc = new RTCPeerConnection(CONFIG)    this.pc.onicecandidate = ({ candidate }) => {      socket.emit('call', {        to: this.remoteId,        candidate      })    }    this.pc.ontrack = ({ streams }) => {      this.emit('remoteStream', streams[0])    }     this.mediaDevice = new MediaDevice()     this.getDescription = this.getDescription.bind(this)  }   start(isCaller, config) {    this.mediaDevice      .on('stream', (stream) => {        stream.getTracks().forEach((t) => {          this.pc.addTrack(t, stream)        })         this.emit('localStream', stream)         isCaller          ? socket.emit('request', { to: this.remoteId })          : this.createOffer()      })      .start(config)     return this  }   stop(isCaller) {    if (isCaller) {      socket.emit('end', { to: this.remoteId })    }    this.mediaDevice.stop()    this.pc.restartIce()    this.off()     return this  }   createOffer() {    this.pc.createOffer().then(this.getDescription).catch(console.error)     return this  }   createAnswer() {    this.pc.createAnswer().then(this.getDescription).catch(console.error)     return this  }   getDescription(desc) {    this.pc.setLocalDescription(desc)     socket.emit('call', { to: this.remoteId, sdp: desc })     return this  }   setRemoteDescription(desc) {    this.pc.setRemoteDescription(new RTCSessionDescription(desc))     return this  }   addIceCandidate(candidate) {    if (candidate) {      this.pc.addIceCandidate(new RTCIceCandidate(candidate))    }     return this  } }  export default PeerConnection

Сначала я реализовал интерфейсы MediaDevice и PeerConnection в виде пользовательских хуков, но мне не понравился результат, поэтому я предпочел вернуться к классам.

Переходим к компонентам.

Компоненты

Начальный экран (components/MainWindow.jsx).

Импортируем хуки, иконки и сокет:

import { useEffect, useState } from 'react' import { BsCameraVideo, BsPhone } from 'react-icons/bs'  import socket from '../utils/socket'  // функция принимает метод для инициализации звонка export const MainWindow = ({ startCall }) => {  // TODO }

Определяем состояния для локального (нашего) и удаленного (в значении «находящийся далеко») id, а также для ошибки:

const [localId, setLocalId] = useState('') const [remoteId, setRemoteId] = useState('') const [error, setError] = useState('')

Отправляем и обрабатываем событие init:

useEffect(() => {  socket    .on('init', ({ id }) => {      // наш `id`, сгенерированный на сервере      setLocalId(id)    })    .emit('init') }, [])

Определяем метод для выполнения звонка:

// звонок может выполняться как с видео, так и без него const callWithVideo = (video) => {  // `id` нашего друга должен быть обязательно указан в соответствующем поле  if (!remoteId.trim()) {    return setError('Your friend ID must be specified!')  }  // настройки для захвата медиапотока  const config = { audio: true, video }  // инициализация `PeerConnection`  startCall(true, remoteId, config) }

Возвращаем разметку:

return (  <div className='container main-window'>    <div className='local-id'>      <h2>Your ID is</h2>      <p>{localId}</p>    </div>    <div className='remote-id'>      <label htmlFor='remoteId'>Your friend ID</label>      <p className='error'>{error}</p>      <input        type='text'        spellCheck={false}        placeholder='Enter friend ID'        onChange={({ target: { value } }) => {          setError('')          setRemoteId(value)        }}      />      <div className='control'>        {/* видео звонок */}        <button onClick={() => callWithVideo(true)}>          <BsCameraVideo />        </button>        {/* аудио звонок */}        <button onClick={() => callWithVideo(false)}>          <BsPhone />        </button>      </div>    </div>  </div> )

Полный код рассматриваемого компонента:

import { useEffect, useState } from 'react' import { BsCameraVideo, BsPhone } from 'react-icons/bs'  import socket from '../utils/socket'  export const MainWindow = ({ startCall }) => {  const [localId, setLocalId] = useState('')  const [remoteId, setRemoteId] = useState('')  const [error, setError] = useState('')   useEffect(() => {    socket      .on('init', ({ id }) => {        setLocalId(id)      })      .emit('init')  }, [])   const callWithVideo = (video) => {    if (!remoteId.trim()) {      return setError('Your friend ID must be specified!')    }    const config = { audio: true, video }    startCall(true, remoteId, config)  }   return (    <div className='container main-window'>      <div className='local-id'>        <h2>Your ID is</h2>        <p>{localId}</p>      </div>      <div className='remote-id'>        <label htmlFor='remoteId'>Your friend ID</label>        <p className='error'>{error}</p>        <input          type='text'          spellCheck={false}          placeholder='Enter friend ID'          onChange={({ target: { value } }) => {            setError('')            setRemoteId(value)          }}        />        <div className='control'>          <button onClick={() => callWithVideo(true)}>            <BsCameraVideo />          </button>          <button onClick={() => callWithVideo(false)}>            <BsPhone />          </button>        </div>      </div>    </div>  ) }


Модальное окно — уведомление о входящем звонке (components/CallModal.jsx).

Импортируем хуки и иконку:

import { BsCameraVideo, BsPhone } from 'react-icons/bs' import { FiPhoneOff } from 'react-icons/fi'  // функция принимает `id` звонящего и методы для принятия звонка и его отклонения export const CallModal = ({ callFrom, startCall, rejectCall }) => {  // TODO }

Определяем метод для принятия звонка:

// звонок может приниматься с видео и без const acceptWithVideo = (video) => {  const config = { audio: true, video }  // инициализация `PeerConnection`  startCall(false, callFrom, config) }

И возвращаем разметку:

return (  <div className='call-modal'>    <div className='inner'>      <p>{`${callFrom} is calling`}</p>      <div className='control'>        {/* принимаем звонок с видео */}        <button onClick={() => acceptWithVideo(true)}>          <BsCameraVideo />        </button>        {/* принимаем звонок без видео */}        <button onClick={() => acceptWithVideo(false)}>          <BsPhone />        </button>        {/* отклоняем звонок */}        <button onClick={rejectCall} className='reject'>          <FiPhoneOff />        </button>      </div>    </div>  </div> )


Экран для коммуникации (components/CallWindow.jsx).

Импортируем хуки и иконки:

import { useState, useEffect, useRef } from 'react' import { BsCameraVideo, BsPhone } from 'react-icons/bs' import { FiPhoneOff } from 'react-icons/fi'  /*  функция принимает следующее:  - удаленный медиа поток  - локальный медиа поток  - настройки для захвата медиапотока  - интерфейс для работы с потоком  - метод для завершения звонка */ export const CallWindow = ({  remoteSrc,  localSrc,  config,  mediaDevice,  finishCall }) => {  // TODO }

Определяем иммутабельные переменные для ссылок на DOM-элементы, а также для размеров элемента с локальным видео и состояния для аудио и видео:

const remoteVideo = useRef() const localVideo = useRef() const localVideoSize = useRef() // настройки могут иметь значение `null`, // поэтому мы используем здесь оператор опциональной последовательности `?.` const [video, setVideo] = useState(config?.video) const [audio, setAudio] = useState(config?.audio)

С точки зрения пользовательского интерфейса мне показалось логичным сделать элемент с удаленным видео очень большим (максимальная высота — 90vh) и разместить его по центру. С размером элемента с локальным видео я тоже определился и сделал его равным 25vw в ширину. Но я не мог определиться с положением этого элемента, поэтому решил реализовать возможность его перетаскивания или, скорее, переноса (клик -> перенос -> клик). При этом, я хотел сделать это наиболее простым и понятным способом.

Определяем состояние для перетаскивания (индикатор) и координат элемента:

const [dragging, setDragging] = useState(false) const [coords, setCoords] = useState({  x: 0,  y: 0 })

Вычисляем размеры элемента с локальным видео и записываем их в переменную:

useEffect(() => {  const { width, height } = localVideo.current.getBoundingClientRect()  localVideoSize.current = { width, height } }, [])

Добавляем визуализацию перетаскивания за счет переключения CSS-классов:

useEffect(() => {  dragging    ? localVideo.current.classList.add('dragging')    : localVideo.current.classList.remove('dragging') }, [dragging])

Определяем метод для перетаскивания элемента с локальным видео:

const onMouseMove = (e) => {  // если элемент находится в состоянии перетаскивания  if (dragging) {    // это позволяет добиться того,    // что центр перетаскиваемого элемента всегда будет следовать за курсором    setCoords({      x: e.clientX - localVideoSize.current.width / 2,      y: e.clientY - localVideoSize.current.height / 2    })  } }

Регистрируем данный обработчик на глобальном объекте window:

useEffect(() => {  window.addEventListener('mousemove', onMouseMove)   return () => {    window.removeEventListener('mousemove', onMouseMove)  } })

Далее, нам необходимо передать медиапотоки в соответствующие элементы:

useEffect(() => {  // удаленный поток  if (remoteVideo.current && remoteSrc) {    remoteVideo.current.srcObject = remoteSrc  }  // локальный поток  if (localVideo.current && localSrc) {    localVideo.current.srcObject = localSrc  } }, [remoteSrc, localSrc])

Переключаем треки в значения, соответствующие настройкам для захвата медиапотока:

useEffect(() => {  if (mediaDevice) {    // переключаем видеотреки    mediaDevice.toggle('Video', video)    // переключаем аудиотреки    mediaDevice.toggle('Audio', audio)  } }, [mediaDevice])

Определяем метод для переключения состояний и треков:

const toggleMediaDevice = (deviceType) => {  // видео  if (deviceType === 'video') {    setVideo(!video)    mediaDevice.toggle('Video')  }  // аудио  if (deviceType === 'audio') {    setAudio(!audio)    mediaDevice.toggle('Audio')  } }

Наконец, возвращаем разметку:

return (  <div className='call-window'>    <div className='inner'>      <div className='video'>        {/* элемент для удаленного видеопотока */}        <video className='remote' ref={remoteVideo} autoPlay />        {/*          элемент для локального видеопотока          обратите внимание на атрибут `muted`,          без него мы будем слышать сами себя,          что сделает коммуникацию затруднительной        */}        <video          className='local'          ref={localVideo}          autoPlay          muted          {/* перенос элемента */}          onClick={() => setDragging(!dragging)}          style={{            top: `${coords.y}px`,            left: `${coords.x}px`          }}        />      </div>      <div className='control'>        {/* кнопка для переключения видео */}        <button          className={video ? '' : 'reject'}          onClick={() => toggleMediaDevice('video')}        >          <BsCameraVideo />        </button>        {/* кнопка для переключения аудио */}        <button          className={audio ? '' : 'reject'}          onClick={() => toggleMediaDevice('audio')}        >          <BsPhone />        </button>        {/* кнопка для завершения звонка */}        <button className='reject' onClick={() => finishCall(true)}>          <FiPhoneOff />        </button>      </div>    </div>  </div> )

Полный код рассматриваемого компонента:

import { useState, useEffect, useRef } from 'react' import { BsCameraVideo, BsPhone } from 'react-icons/bs' import { FiPhoneOff } from 'react-icons/fi'  export const CallWindow = ({  remoteSrc,  localSrc,  config,  mediaDevice,  finishCall }) => {  const remoteVideo = useRef()  const localVideo = useRef()  const localVideoSize = useRef()  const [video, setVideo] = useState(config?.video)  const [audio, setAudio] = useState(config?.audio)   const [dragging, setDragging] = useState(false)  const [coords, setCoords] = useState({    x: 0,    y: 0  })   useEffect(() => {    const { width, height } = localVideo.current.getBoundingClientRect()    localVideoSize.current = { width, height }  }, [])   useEffect(() => {    dragging      ? localVideo.current.classList.add('dragging')      : localVideo.current.classList.remove('dragging')  }, [dragging])   useEffect(() => {    window.addEventListener('mousemove', onMouseMove)     return () => {      window.removeEventListener('mousemove', onMouseMove)    }  })   useEffect(() => {    if (remoteVideo.current && remoteSrc) {      remoteVideo.current.srcObject = remoteSrc    }    if (localVideo.current && localSrc) {      localVideo.current.srcObject = localSrc    }  }, [remoteSrc, localSrc])   useEffect(() => {    if (mediaDevice) {      mediaDevice.toggle('Video', video)      mediaDevice.toggle('Audio', audio)    }  }, [mediaDevice])   const onMouseMove = (e) => {    if (dragging) {      setCoords({        x: e.clientX - localVideoSize.current.width / 2,        y: e.clientY - localVideoSize.current.height / 2      })    }  }   const toggleMediaDevice = (deviceType) => {    if (deviceType === 'video') {      setVideo(!video)      mediaDevice.toggle('Video')    }    if (deviceType === 'audio') {      setAudio(!audio)      mediaDevice.toggle('Audio')    }  }   return (    <div className='call-window'>      <div className='inner'>        <div className='video'>          <video className='remote' ref={remoteVideo} autoPlay />          <video            className='local'            ref={localVideo}            autoPlay            muted            onClick={() => setDragging(!dragging)}            style={{              top: `${coords.y}px`,              left: `${coords.x}px`            }}          />        </div>        <div className='control'>          <button            className={video ? '' : 'reject'}            onClick={() => toggleMediaDevice('video')}          >            <BsCameraVideo />          </button>          <button            className={audio ? '' : 'reject'}            onClick={() => toggleMediaDevice('audio')}          >            <BsPhone />          </button>          <button className='reject' onClick={() => finishCall(true)}>            <FiPhoneOff />          </button>        </div>      </div>    </div>  ) }


Основной компонент приложения (App.jsx).

Импортируем стили, хуки, иконку, интерфейс PeerConnection и сокет:

import './styles/app.scss'  import { useState, useEffect } from 'react' import { BsPhoneVibrate } from 'react-icons/bs'  import PeerConnection from './utils/PeerConnection' import socket from './utils/socket'  import { MainWindow, CallWindow, CallModal } from './components'  export default function App() {  // TODO }

Определяем состояния для:

  • id звонящего;
  • индикатора установки соединения;
  • индикатора отображения уведомления;
  • локального медиапотока;
  • удаленного медиапотока;
  • экземпляра PeerConnetion;
  • настроек для медиа.

const [callFrom, setCallFrom] = useState('') const [calling, setCalling] = useState(false)  const [showModal, setShowModal] = useState(false)  const [localSrc, setLocalSrc] = useState(null) const [remoteSrc, setRemoteSrc] = useState(null)  const [pc, setPc] = useState(null) const [config, setConfig] = useState(null)

Регистрируем обработку запроса на установку соединения:

useEffect(() => {  socket.on('request', ({ from }) => {    // записываем `id` звонящего    setCallFrom(from)    // показываем модальное окно    setShowModal(true)  }) }, [])

Регистрируем обработку подготовки к подключению и завершения звонка:

// регистрация обработчиков осуществляется только после создания // экземпляра `PeerConnection` - это является критически важным useEffect(() => {  if (!pc) return   socket    // обработка подготовки к подключению    // данные могут содержать предложение, ответ и кандидата ICE (в том числе, в виде пустой строки - нулевой кандидат)    .on('call', (data) => {      // если данные содержат описание      if (data.sdp) {        pc.setRemoteDescription(data.sdp)         // если данные содержат предложение        if (data.sdp.type === 'offer') {          // генерируем ответ          pc.createAnswer()        }      } else {        // добавляем кандидата        pc.addIceCandidate(data.candidate)      }    })    // обработка завершения звонка    .on('end', () => finishCall(false)) }, [pc])

Определяем метод для инициализации звонка:

/*  функция принимает 3 параметра:  - является ли пользователь инициатором звонка  - `id` адресата  - настройки для медиа */ const startCall = (isCaller, remoteId, config) => {  // скрываем модельное окно - для случая, когда мы принимаем звонок  setShowModal(false)  // отображаем индикатор подключения  setCalling(true)  // сохраняем настройки  setConfig(config)   // создаем экземпляр `PeerConnection`,  // передавая ему `id` адресата  const _pc = new PeerConnection(remoteId)    // обработка получения локального потока    .on('localStream', (stream) => {      setLocalSrc(stream)    })    // обработка получения удаленного потока    .on('remoteStream', (stream) => {      setRemoteSrc(stream)      // скрываем индикатор установки соединения      setCalling(false)    })    // запускаем `PeerConnection`    .start(isCaller, config)   // записываем экземпляр `PeerConnection`  // это приводит к регистрации обработчиков  // подготовки к звонку и его завершения  setPc(_pc) }

Определяем метод для отклонения звонка:

const rejectCall = () => {  socket.emit('end', { to: callFrom })   setShowModal(false) }

Определяем метод для завершения звонка:

const finishCall = (isCaller) => {  // выполняем перезагрузку `WebRTC`  pc.stop(isCaller)   // обнуляем состояния  setPc(null)  setConfig(null)   setCalling(false)  setShowModal(false)   setLocalSrc(null)  setRemoteSrc(null) }

И возвращаем разметку:

return (  <div className='app'>    <h1>React WebRTC</h1>    {/* начальный экран */}    <MainWindow startCall={startCall} />    {/* индикатор подключения */}    {calling && (      <div className='calling'>        <button disabled>          <BsPhoneVibrate />        </button>      </div>    )}    {/* модальное окно */}    {showModal && (      <CallModal        callFrom={callFrom}        startCall={startCall}        rejectCall={rejectCall}      />    )}    {/* экран коммуникации */}    {remoteSrc && (      <CallWindow        localSrc={localSrc}        remoteSrc={remoteSrc}        config={config}        mediaDevice={pc?.mediaDevice}        finishCall={finishCall}      />    )}  </div> )

Полный код рассматриваемого компонента:

import './styles/app.scss'  import { useState, useEffect } from 'react' import { BsPhoneVibrate } from 'react-icons/bs'  import PeerConnection from './utils/PeerConnection' import socket from './utils/socket'  import { MainWindow, CallWindow, CallModal } from './components'  export default function App() {  const [callFrom, setCallFrom] = useState('')  const [calling, setCalling] = useState(false)   const [showModal, setShowModal] = useState(false)   const [localSrc, setLocalSrc] = useState(null)  const [remoteSrc, setRemoteSrc] = useState(null)   const [pc, setPc] = useState(null)  const [config, setConfig] = useState(null)   useEffect(() => {    socket.on('request', ({ from }) => {      setCallFrom(from)      setShowModal(true)    })  }, [])   useEffect(() => {    if (!pc) return     socket      .on('call', (data) => {        if (data.sdp) {          pc.setRemoteDescription(data.sdp)           if (data.sdp.type === 'offer') {            pc.createAnswer()          }        } else {          pc.addIceCandidate(data.candidate)        }      })      .on('end', () => finishCall(false))  }, [pc])   const startCall = (isCaller, remoteId, config) => {    setShowModal(false)    setCalling(true)    setConfig(config)     const _pc = new PeerConnection(remoteId)      .on('localStream', (stream) => {        setLocalSrc(stream)      })      .on('remoteStream', (stream) => {        setRemoteSrc(stream)        setCalling(false)      })      .start(isCaller, config)     setPc(_pc)  }   const rejectCall = () => {    socket.emit('end', { to: callFrom })     setShowModal(false)  }   const finishCall = (isCaller) => {    pc.stop(isCaller)     setPc(null)    setConfig(null)     setCalling(false)    setShowModal(false)     setLocalSrc(null)    setRemoteSrc(null)  }   return (    <div className='app'>      <h1>React WebRTC</h1>      <MainWindow startCall={startCall} />      {calling && (        <div className='calling'>          <button disabled>            <BsPhoneVibrate />          </button>        </div>      )}      {showModal && (        <CallModal          callFrom={callFrom}          startCall={startCall}          rejectCall={rejectCall}        />      )}      {remoteSrc && (        <CallWindow          localSrc={localSrc}          remoteSrc={remoteSrc}          config={config}          mediaDevice={pc?.mediaDevice}          finishCall={finishCall}        />      )}    </div>  ) }

Проверка работоспособности

Поднимаемся в корневую директорию (react-webrtc), инициализируем Node.js-проект и устанавливаем concurrently — утилиту для одновременного выполнения нескольких команд, определенных в package.json:

cd ..  yarn init -yp  yarn add concurrently

Определяем в package.json команду для запуска приложения в режиме для разработки:

"scripts": {  "dev": "concurrently \"yarn --cwd server dev\" \"yarn --cwd client dev\"" }

Открываем терминал и выполняем данную команду с помощью yarn dev.

Получаем сообщения о запуске сервера по адресу http://localhost:4000 и клиента по адресу http://localhost:3000.

Открываем 2 вкладки браузера с клиентом:

Копируем id с одной вкладки, вставляем его в поле Your friend ID в другой вкладке и нажимаем на иконку видеокамеры.

Браузер запрашивает наше разрешение на использование видеокамеры и микрофона. Предоставляем ему такое разрешение. В текущей вкладке появляется индикатор подключения, а в другой — уведомление о входящем звонке:

Принимаем звонок с видео. Появляется экран коммуникации:

Переключаем видео и аудио, меняем положение элемента с локальным видео:

И завершаем звонок.

Круто! Приложение работает, как ожидается.

Пожалуй, это все, чем я хотел поделиться с вами в данной статье.

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/649369/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *