Всем привет, дорогие читатели Хабр. Мы долго думали, чтобы нам сделать такое, что от нас не потребует глубоких знаний бэкенда и базы данных, но все же интересное и обучающее, исключительно ориентированное на конечного пользователя. Так мы пришли к тому, что нам бы хотелось изучить более подробно сферу WebRTC и WebSockets и решили сделать что-то похожее на Google Meet c еë основными фичами, которые более подробно описаны чуть ниже. Но давайте все по порядку :) Приготовьтесь, будет много кода!

Стек технологий

Для клиентской части мы выбрали NextJS, этот фреймворк из коробки даёт нам возможность создать "endpoint handler" в два щелчка (это нам понадобится для SocketIO), плюс супер легко настраивать навигацию по страницам.

Для стилизации, наш выбор пал на TailwindCSS, потому что мы с ним никогда раньше не работали и нам хотелось попробовать.

Прочитав пару тройку статей и просмотрев несколько видео, мы узнали, что WebRTC в чистом виде является громоздким и неповоротливым. В связи с этим перед нами стоял выбор между библиотеками PeerJS и Simple-Peer. Поэкспериментировав обеими библиотеками, для проекта мы остановились на PeerJS. PeerJS покрывает все настройки по умолчанию такие как STUN server, ICE candidate, так что можно не беспокоиться насчëт этого. Для нас это было супер решением из-за наших скудных знаний в этой области.

Более подробно о протоколах WebRTC можете изучить здесь.

Когда вы работаете с WebRTC технологией, у вас также должен быть Signaling Server в роли синхронизатора. Этот сервер поможет вам держать всех участников обновлёнными и реагировать соответственно в случае тех или иных событий пользователя. В качестве Signaling Server у нас служит SocketIO.

Для полного антуража, мы также подкрутили базовую авторизацию на платформе Auth0.

Фичи

Так какие же фичи нас ждут в этом приложении:

  • страница-лобби для первоначальных настроек перед входом в комнату

  • создание комнаты

  • особые возможности организатора комнаты

  • демонстрация экрана

  • отключение светового индикатора при выключении камеры

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

  • обмен сообщениями в реальном времени

  • список гостей в комнате с их статусами

Перед тем как приступить к разработке вышеперечисленных фич приложения нам потребовалось установить несколько библиотек и настроить их под наши задачи. Ниже вы можете найти ссылки на наши Pull Request-ы и официальные документации:

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

|- app
|----| index.tsx
|- components
|----| lobby.tsx
|----| control-panel.tsx
|----| chat.tsx
|----| status.tsx
|- contexts
|----| users-connection.tsx
|----| users-settings.tsx
|- hooks
|----| use-is-audio-active.ts
|----| use-media-stream.ts
|----| use-peer.ts
|----| use-screen.ts
|- pages
|----| index.tsx
|----| room
|--------| [roomId].tsx

Переход на страницу-лобби

На главной странице приложения у пользователя есть две возможности: создать новую комнату или присоединиться к существующей. Независимо от выбора действия, пользователь попадает на страницу-лобби, где пользователь может предварительно вкл/выкл своë аудио/видео перед входом в комнату. Логика "страницы-лобби" реализована через useState.

// pages/[roomId].tsx

export default function Room(): NextPage {
  const [isLobby, setIsLobby] = useState(true);
  const { stream } = useMediaStream();
  
  return isLobby
    ? <Lobby stream={stream} onJoinRoom={() => setIsLobby(false)} />
    : <Room stream={stream} />;
}

Так как мы работаем с видео и аудио, а они являются потоком данных в определенном промежутке времени, и чтобы комфортно работать с этим типом нам нужен подходящий интерфейс. К счастью он у нас есть. Протокол MediaCapture и Streams API позволяет создать нам поток (stream) медиа данных. Поток состоит из нескольких треков (tracks), таких как видео и аудио. Наш кастомный хук useMediaStream берëт ответственность за создание и манипуляцию над потоком.

На странице-лобби происходят два действия:

  1. управление первоначальными настройками стрима

  2. вход в комнату.

// components/lobby.tsx
// pseudocode

const Lobby = ({
  stream,
  onJoinRoom,
}: {
  stream: MediaStream;
  onJoinRoom: () => void;
}) => {
  const { toggleAudio, toggleVideo } = useMediaStream(stream);
  
  return (
    <>
        <video srcObject={stream} />
        <button onClick={toggleVideo}>Toggle video</button>
        <button onClick={toggleAudio}>Toggle audio</button>
        <button onClick={onJoinRoom}>Join</button>
    </>
  );
};

Просим заметить, что если track.enabled = true (track - audio / video), то состояние трека выводится из источника к слушателям без посредников (WebSocket, PeerJS), иначе, выводятся пустые кадры. Исходя из этого, не нужно хранить локальный стэйт для того чтобы вкл/выкл аудио/видео.

Полный код реализаций страницы-лобби можете посмотреть здесь.

Переход в комнату

Допустим, что человек на странице-лобби выбрал аудио и видео включенными и затем переходит в комнату. На этом моменте начинается всë самое интересное ? ? ?. Первым делом после входа в комнату создаются основные две сущности для успешной связи с остальными, кто находится в этой комнате: peer и socket. Peer cоединение необходимо для передачи стрима другому пользователю, а Socket служит в роли переносчика состояния данного стрима.

Так как теперь у нас есть доступ до roomId (берем из useRouter) и user (берем из useUser), самое время создать peer и сообщить всем остальным, что вы тоже в тусовке.

// hooks/use-peer.ts
// core part of the code

// open connection
peer.on('open', (id: PeerId) => {
  // tell others new user joined the room
  socket.emit('room:join', {
    roomId, // which room to connect to
    user: { id, name: user.name, muted, visible } // joining user's data
  });
});

Сам хук по созданию peer здесь.

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

// app/index.tsx
// pseudocode

// stream comes from Lobby page
export default App({ stream }: { stream: MediaStream }) => {
  const socket = useContext(SocketContext);
  const peer = usePeer(stream);
  
  return (
    <UsersStateContext.Provider>
      <UsersConnectionContext.Provider value={{ peer, stream }}>
        <MyStream />
        <OthersStream />
        <ControlPanel />
      </UsersConnectionContext.Provider>
    </UsersStateContext.Provider>
);

};

UsersStateContext берет ответственность за изменение и распространение состояний пользователей. В свою очередь UsersConnectionContext отвечает за коммуникацию: вход в комнату, осуществление связи между пользователями, выход из комнаты и демонстрация экрана. Да, демонстрацию экрана мы включили в коммуникацию, так как при демонстрации создаëтся новый поток медиаданных, но об этом чуть позже)

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

// contexts/users-connection.tsx

// event is listened on users who are already in the room
socket.on('user:joined', ({ id, name }: UserConfig) => {
  // call to newly joined user's id with my stream and my name
  const call = peer.call(id, stream, {
    metadata: {
      username: user.name,
    },
  });
});

В данном случае наш Signaling Server - socket как бы говорит всем присутствующим ‘Йоу, тут у нас новый гость’ при помощи события user:joined , и после этого каждый кто в комнате подходит к гостю и знакомится, даëт ему свой поток медиаданных (stream), а также называет своë имя. А в ответ принимают имя, стрим и айдишник новичка:

// contexts/users-connection.tsx

// newly joined user's stream, name and id
call.on('stream', (stream) => appendVideoStream({ id, name })(stream));

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

// contexts/users-connection.tsx

// action below happens on the newly joined user's device
peer.on('call', (call) => {
  const { peer, metadata } = call;
  const { user } = metadata;
  
  // answers incoming call with the stream
  call.answer(stream); 
  
  // stream, name and id of user who was already in the room
  call.on('stream', (stream) => appendVideoStream({ id: peer, name: user.name })(stream));
});

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

Контроль управления

На данном этапе в роль вступает "контроль управления" над собственным стримом:

  • переключение аудио

  • переключение видео

  • выход из комнаты

  • демонстрация экрана

// app/index.tsx

<ControlPanel
  visible={visible}
  muted={muted}
  onLeave={() => router.push('/')}
  onToggle={onToggle}
/>

Метод onLeave вызывается при выходе из комнаты. Метод onToggle используется для переключения аудио/видео и управления демонстрацией экрана. Реализация метода выглядит следующим образом:

// app/index.tsx

// only related part of the code
const { toggleAudio, toggleVideo } = useMediaStream(stream);
const { myId } = usePeer(stream);
const { startShare, stopShare, screenTrack } = useScreen(stream);

async function onToggle(
  kind: Kind,
  users?: MediaConnection[]
) {
  switch (kind) {
    case 'audio': {
      toggleAudio();
      socket.emit('user:toggle-audio', myId);
      return;
    }
    case 'video': {
      toggleVideo(
        (newVideoTrack: MediaTrack) => {
          users.forEach((user) => replaceTrack(user)(newVideoTrack))
        }
      );
      socket.emit('user:toggle-video', myId);
      return;
    }
    case 'screen': {
      if (screenTrack) {
        stopShare(screenTrack);
        socket.emit('user:stop-share-screen');
      } else {
        await startShare(
          () => socket.emit('user:share-screen'),
          () => socket.emit('user:stop-share-screen')
        );
      }
      return;
    }
    default:
      break;
  }
}

Функции toggleAudio и toggleVideo работают почти идентично: меняют состояние muted/visible и переключают свойство enabled у соответствующего трека, но есть один маленький нюанс который их различает, об этом чуть ниже.

Демонстрация экрана

Для управления демонстрацией экрана используется также кастомный хук useScreen, в которой реализованы методы startShare и stopShare.

// hooks/use-screen.ts

async function startShare(
  onstarted: () => void,
  onended: () => void
) {
  const screenStream = await navigator.mediaDevices.getDisplayMedia({
    video: true,
    audio: false,
  });
  const [screenTrack] = screenStream.getTracks();
  setScreenTrack(screenTrack);
  stream.addTrack(screenTrack);
  
  onstarted();
  
  // once screen is shared, tiny popup will appear with two buttons - Stop sharing, Hide
  // they are NOT custom, and come as they are
  // so .onended is triggered when user clicks "Stop sharing"
  screenTrack.onended = () => {
    stopShare(screenTrack);
    onended();
  };
}

При демонстрации экрана мы создаем новый стрим и добавляем его видео-дорожку в свой текущий стрим. В конечном итоге, в нашем потоке одна аудио- и две видео-дорожки (userMediaTrack, displayMediaTrack). Далее мы уведомляем пользователей в комнате событием user:share-screen, соответственно слушатели реагируют на событие user:shared-screen перезагрузкой peer соединения с целью получить обновлëнный стрим с дополнительной дорожкой экрана.

// contexts/users-connection.tsx

socket.on('user:shared-screen', () => {
  // peer connection reset
  peer.disconnect();
  peer.reconnect();
});

При остановке демонстрации экрана происходит обратное: мы останавливаем видео-дорожку экрана с помощью метода stop и убираем еë из нашего стрима.

// hooks/use-screen.ts

function stopShare(screenTrack: MediaStreamTrack) {
  screenTrack.stop();
  stream.removeTrack(screenTrack);
}

Контроль управления организатора комнаты

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

// components/video-container/index.tsx
// pseudocode

// wrapper around the stream takes a responsibility to render
// corresponding component or icon depending on the state of stream
function VideoContainer({
  children,
  id,
  onMutePeer,
  onRemovePeer
}: {
  children: React.ReactNode,
  id: PeerId,
  onMutePeer: (id: PeerId) => void,
  onRemovePeer: (id: PeerId) => void
}) {
  return (
    <>
      <div>
        /* here goes video stream component */
        {children}
      </div>
    
      /* show host control panel if I created the room */
      {isHost && (myId !== id) && (
        <HostControlPanel
          onMutePeer={() => onMutePeer && onMutePeer(id)}
          onRemovePeer={() => onRemovePeer && onRemovePeer(id)}
          isMuted={muted}
        />
      )}
    </>
  )
}

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

Однако не все так просто с событием onRemovePeer, тут происходят аж 3 действия:

  1. дать сигнал всем остальным с айдишником, чтобы они отреагировали соответственно

  2. в своей комнате убрать пользователя, обновить стейт streams

  3. закрыть peer соединение

// contexts/users-connection.tsx

function leaveRoom(id: PeerId) {
  // сообщаем всем
  socket.emit('user:leave', id);
  
  // закрываем соединение
  users[id].close();
  
  // убираем пользователя из комнаты визуально
  setStreams((streams) => {
    const copy = {...streams};
    delete copy[id];
    return copy;
  });
}

Отключение светового индикатора при выключении камеры

Вот мы и подошли к нюансу различия между toggleAudio и toggleVideo.

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

// hooks/use-media-stream.ts

// @param onTurnVideoOn - optional callback that takes newly created video track
async function toggleVideo(onTurnVideoOn?: (track: MediaTrack) => void) {
  const videoTrack = stream.getVideoTracks()[0];
  
  if (videoTrack.readyState === 'live') {
    videoTrack.enabled = false;
    videoTrack.stop(); // turns off web cam light indicator
  } else {
    const newStream = await navigator.mediaDevices.getUserMedia({
      video: true,
  	  audio: false,
    });
    const newVideoTrack = newStream.getVideoTracks()[0];
  
    if (typeof onTurnVideoOn === 'function') onTurnVideoOn(newVideoTrack);
  
    stream.removeTrack(videoTrack);
    stream.addTrack(newVideoTrack);

    setStream(stream);
  }
}

Значение false свойства enabled видео-дорожки не отключает световой индикатор, поэтому мы вызываем метод stop интерфейса MediaTrack, который в свою очередь говорит браузеру, что этот трек больше не нужен. Сразу после вызова метода stop(), свойство трека readyState переходит в состояние ended. К сожалению, у интерфейса MediaTrack нет метода start или restart, как вы уже могли подумать. Соответственно для обратного включения видео нам нужно создать новый стрим, вытащить из него видео-дорожку, а затем заменить старую видео-дорожку на новую.

Постойте, мы тут меняем у себя видео-дорожку направо и налево, а как об этом узнают другие люди в комнате? Спокойно, функция replaceTrack got your back:

// app/index.tsx

// @param track - new track to replace old track
function replaceTrack(track: MediaStreamTrack) {
  return (peer: MediaConnection) => {
    const sender = peer.peerConnection
      .getSenders()
      .find((s) => s.track.kind === track.kind);
    
    sender?.replaceTrack(track);
  }
}

Допустим, что моя веб-камера выключена. Давайте разберëм, что произойдет при еë обратном включении. Мы передаëм опциональный колбэк в функцию toggleVideo, который единственным параметром принимает новый видео-трек. В колбэке мы заменяем свой старый видео-трек на новый для каждого пользователя в комнате. Для этого на помощь приходит интерфейс RTCPeerConnection с методом getSenders, который возвращает массив из RTCRtpSender. RTCRtpSender – это объект, дающий возможность для манипуляции над медиа-треком, который мы отправляем всем пользователям.

Вот такие пироги, господа.

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

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

Так как аудио это поток медиаданных и его сложно интерпретировать как что-то целое, WebAPI нам даëт поддержку с помощью интерфейса AudioContext. AudioContext позволяет нам обрабатывать аудио как отдельные сегменты потока. Из-за того, что мы должны анализировать аудио во временном отрезке, мы будем использовать интерфейс из WebAudio API - AnalyserNode.

// hooks/use-is-audio-active.ts

const audioContext = new AudioContext();
const analyser = new AnalyserNode(audioContext, { fftSize });

// source is a stream (MediaStream)
const audioSource = audioContext.createMediaStreamSource(source);

// connect your audio source to output (usually laptop's mic), here it is analyser in terms of time domain
audioSource.connect(analyser);

В зависимости от переданного значения FFT (Fast Fourier Transform), и с использованием requestAnimationFrame мы распознаем говорит ли пользователь в данный момент, возвращая булевое значение каждую секунду. По сути FFT заранее определяет промежуток частот, с которым analyser будет работать. Подробнее про это можете почитать здесь и здесь.

// hooks/use-is-audio-active.ts

// buffer length gives us how many different frequencies we are going to be measuring
const bufferLength = analyser.frequencyBinCount;

// array with 512 length (half of FFT) and filled with 0-s
const dataArray = new Uint8Array(bufferLength);
update();

function update() {
  // fills up dataArray with ~128 samples for each index
  analyser.getByteTimeDomainData(dataArray);
  
  const sum = dataArray.reduce((a, b) => a + b, 0);
  
  if (sum / dataArray.length / 128.0 >= 1) {
    setIsSpeaking(true);
    setTimeout(() => setIsSpeaking(false), 1000);
  }
  
  requestAnimationFrame(update);
}

Обмен сообщениями в реальном времени

Как заявлено выше, в нашем приложении реализована возможность обмениваться текстовыми сообщениями в реальном времени. При отправке сообщения пользователем, сервер получает событие chat:post с объектом message, в котором хранятся имя пользователя, отправившего сообщение, текст сообщения и время отправки в формате hh:mm. Данное событие вызывает у слушателей событие chat:get, что позволит им обновить стейт messages, добавив в него новое сообщение, отправленное пользователем.

// components/chat/index.tsx

function Chat() {
  const [text, setText] = useState('');
  const [messages, setMessages] = useState<UserMessage[]>([]);
  
  useEffect(() => {
    socket.on('chat:get', (message: UserMessage) =>
      setMessages(append(message))
    );
  }, []);
  
  return (
    <>
      <MessagesContainer messages={messages}/>
      <Input
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={sendMessage}
      />
    </>
  );
}

Реализация функции sendMessage для отправки сообщения всем пользователям в комнате:

// components/chat/index.tsx

function sendMessage(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.key === 'Enter' && text) {
    const message = {
      user: username,
      text,
      time: formatTimeHHMM(Date.now()),
    };
    
    socket.emit('chat:post', message);
    setMessages(append(message));
    setText('');
  }
}

Список гостей в комнате с их статусами

Для отображения списка пользователей, находящихся в комнате, с их актуальными статусами мы используем вышеупомянутый UsersStateContext, в котором хранятся все необходимые нам данные о пользователях: id, аватар, имя пользователя и состояние стрима.

// components/status/index.tsx

const Status = ({ muted, visible }: { muted: boolean; visible: boolean }) => {
  const { avatars, muted, visible, names } = useContext(UsersStateContext);
  const usersIds = Object.keys(names);
  
  return (
    <>
      {usersIds.map((id) => (
        <div>
          <img src={avatars[id]} alt="User image" />
          <span>{names[id]}</span>
          <Icon variant={muted[id] ? 'muted' : 'not-muted'} />
          <Icon variant={visible[id] ? 'visible' : 'not-visible'} />
        </div>
      ))}
    </>
  );
};

Таким образом, мы вам показали все основные части приложения, которые хотели описать. Надеемся, что вам понравилось и вы узнали кое-что новое.

Заключение

Окончив этот проект, мы поняли, что мы затронули только поверхность WebRTC и WebSockets. Но всë же нам удалось более-менее реализовать основные фичи видео-чат приложений. По сути для нас этот проект стал песочницей для экспериментов с WebRTC, WebSockets, Tailwind и Auth0. Исходный код можете найти здесь, демо проекта – здесь.

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

P.S. В этом приложении есть несколько багов, о которых мы знаем, и, по мере нашего свободного времени, мы постараемся их исправить.