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

от автора

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

Задумка

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

В JavaScript есть библиотека React, которая позволяет создавать сайт. Выбрал я не бездумно, ведь React можно связать с сайтом на Jango(питон), следовательно в будущем можно сделать дизайн и будет красивее и привлекательнее. А сейчас же у меня стояла задача сделать работоспособный видеочат.

Реализация сервера и чата

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

Так как сервер у меня будет на Js, то я сразу установил Node Js, который позволяет выполнять написанный код на этом языке программирования. Сначала я создал само react-приложение.

npx create-react-app video-chat-webrtc

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

cd video-chat-webrtc npm i express socket.io socket.io-client react-router react-router-dom uuid freeice --save npm run start

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

Затем, я изменил файл /video-chat-webrtc/src/App.js. Так как у меня будет пока 3 пути, на которые мы можем перейти(Room, Main, NotFound404). Также я сделал папку pages, где находятся другие 3 папки: Room, Main и NotFound404, где будут находится Js файлы, каждый отвечающий за свою страницу на сайте.

import {BrowserRouter, Switch, Route} from 'react-router-dom'; import Room from './pages/Room'; import Main from './pages/Main'; import NotFound404 from './pages/NotFound404';  function App() {   return (     <BrowserRouter>       <Switch>         <Route exact path='/room/:id' component={Room}/>         <Route exact path='/' component={Main}/>         <Route component={NotFound404}/>       </Switch>     </BrowserRouter>   ); }  export default App;

Затем, после создания роутеров на страницы сайта, я наконец задумался над тем, на каком порте будет висеть сайт, будет протокол защищенный или нет и ,наконец, над созданием самой логики подключения и отключение клиента. То есть я задумался над созданием файла, в котором все это будет реализовано. В моем проекте это файл — server.js

const fs = require('fs'); const options = { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') };  const path = require('path'); const express = require('express'); const app = express(); const server = require('http').createServer(app); const serverHttps = require('https').createServer(options, app); const io = require('socket.io')(serverHttps); const PORT = process.env.PORT || 3006;  server.listen(PORT, () => { console.log('Server Started!') } )  serverHttps.listen(3010, () => { console.log("Https server Started!") } )

Как вы видите, одновременно стартует 2 сервера: http и https. Почему так? Когда я запускал впервые, то при подключении к http серверу, данные не отправлялись, то есть браузер не запрашивал разрешение на передачу медиаконтента. Посидев в интернете и поискав решение проблемы, я понял, что необходимо сделать https сервер, так как он передает медиаконтент по защищенному каналу. Следовательно, мне надо было сделать сертификаты. Так как я был на линуксе, то сделать такие не составило труда, правда он получился битым, из-за чего браузер говорил, что подключение не известно и не защищенно, но его же делал я, так что мне нечего было бояться.

Далее я создавал подключение на клиенте(т.е алгоритм, который будет подключать юзера к серверу, и который будет находиться на стороне клиента). В папке scr я сделал новую папку socket, где сделал файл index.js.

import {io} from 'socket.io-client';  const options = { "force new connection": true, reconnectionAttempts: "Infinity", // avoid having user reconnect manually in order to prevent dead clients after a server restart timeout : 10000, // before connect_error and connect_timeout are emitted. transports : ["websocket"] }  const socket = io('/', options);  export default socket;

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

const ACTIONS = { JOIN: 'join', LEAVE: 'leave', SHARE_ROOMS: 'share-rooms', ADD_PEER: 'add-peer', REMOVE_PEER: 'remove-peer', RELAY_SDP: 'relay-sdp', RELAY_ICE: 'relay-ice', ICE_CANDIDATE: 'ice-candidate', SESSION_DESCRIPTION: 'session-description' };  module.exports = ACTIONS;

В файл server.js я добавил:

const ACTIONS = require('./src/socket/actions');  function getClientRooms() { const {rooms} = io.sockets.adapter;  return Array.from(rooms.keys()); }  function shareRoomsInfo() { io.emit(ACTIONS.SHARE_ROOMS, { rooms: getClientRooms() }) }  io.on('connection', socket => { shareRoomsInfo();  socket.on(ACTIONS.JOIN, config => { const {room: roomID} = config; const {rooms: joinedRooms} = socket;  if (Array.from(joinedRooms).includes(roomID)) { return console.warn(`Already joined to ${roomID}`); }  const clients = Array.from(io.sockets.adapter.rooms.get(roomID) || []);  clients.forEach(clientID => { io.to(clientID).emit(ACTIONS.ADD_PEER, { peerID: socket.id, createOffer: false });  socket.emit(ACTIONS.ADD_PEER, { peerID: clientID, createOffer: true, }); });  socket.join(roomID); shareRoomsInfo(); });  function leaveRoom() { const {rooms} = socket;  Array.from(rooms) // LEAVE ONLY CLIENT CREATED ROOM .forEach(roomID => { const clients = Array.from(io.sockets.adapter.rooms.get(roomID) || []);  clients.forEach(clientID => { io.to(clientID).emit(ACTIONS.REMOVE_PEER, { peerID: socket.id, });  socket.emit(ACTIONS.REMOVE_PEER, { peerID: clientID, }); });  socket.leave(roomID); });  shareRoomsInfo(); }  socket.on(ACTIONS.LEAVE, leaveRoom); socket.on('disconnecting', leaveRoom);

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

import {useState, useEffect, useRef} from 'react'; import socket from '../../socket'; import ACTIONS from '../../socket/actions'; import {useHistory} from 'react-router'; import {v4} from 'uuid';  export default function Main() { const history = useHistory(); const [rooms, updateRooms] = useState([]); const rootNode = useRef();  useEffect(() => { socket.on(ACTIONS.SHARE_ROOMS, ({rooms = []} = {}) => {  }); }, []);  return ( <div> <h1>Available Rooms</h1>  <ul> {rooms.map(roomID => ( <li key={roomID}> {roomID} <button onClick={() => { history.push(`/room/${roomID}`); }}>JOIN ROOM</button> </li> ))} </ul>  <button onClick={() => { history.push(`/room/${v4()}`); }}>Create New Room</button> </div> ); }

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

function getClientRooms() { const {rooms} = io.sockets.adapter; return Array.from(rooms.keys()).filter(roomID => validate(roomID) && version(roomID) === 4); }

Потом я стал реализовывать сами комнаты. Для отображение изображений необходимы были хуки, в которых будем подписываться на все события. Я создал папку src/hooks, а в ней файл useWebRTC.js

import {useEffect, useRef, useCallback} from 'react'; import freeice from 'freeice'; import useStateWithCallback from './useStateWithCallback'; import socket from '../socket'; import ACTIONS from '../socket/actions';  export const LOCAL_VIDEO = 'LOCAL_VIDEO';   export default function useWebRTC(roomID) {   const [clients, updateClients] = useStateWithCallback([]);    const addNewClient = useCallback((newClient, cb) => {     updateClients(list => {       if (!list.includes(newClient)) {         return [...list, newClient]       }        return list;     }, cb);   }, [clients, updateClients]);    const peerConnections = useRef({});   const localMediaStream = useRef(null);   const peerMediaElements = useRef({     [LOCAL_VIDEO]: null,   });    useEffect(() => {     async function handleNewPeer({peerID, createOffer}) {       if (peerID in peerConnections.current) {         return console.warn(`Already connected to peer ${peerID}`);       }        peerConnections.current[peerID] = new RTCPeerConnection({         iceServers: freeice(),       });        peerConnections.current[peerID].onicecandidate = event => {         if (event.candidate) {           socket.emit(ACTIONS.RELAY_ICE, {             peerID,             iceCandidate: event.candidate,           });         }       }        let tracksNumber = 0;       peerConnections.current[peerID].ontrack = ({streams: [remoteStream]}) => {         tracksNumber++          if (tracksNumber === 2) { // video & audio tracks received           tracksNumber = 0;           addNewClient(peerID, () => {             if (peerMediaElements.current[peerID]) {               peerMediaElements.current[peerID].srcObject = remoteStream;             } else {               // FIX LONG RENDER IN CASE OF MANY CLIENTS               let settled = false;               const interval = setInterval(() => {                 if (peerMediaElements.current[peerID]) {                   peerMediaElements.current[peerID].srcObject = remoteStream;                   settled = true;                 }                  if (settled) {                   clearInterval(interval);                 }               }, 1000);             }           });         }       }        localMediaStream.current.getTracks().forEach(track => {         peerConnections.current[peerID].addTrack(track, localMediaStream.current);       });        if (createOffer) {         const offer = await peerConnections.current[peerID].createOffer();          await peerConnections.current[peerID].setLocalDescription(offer);          socket.emit(ACTIONS.RELAY_SDP, {           peerID,           sessionDescription: offer,         });       }     }      socket.on(ACTIONS.ADD_PEER, handleNewPeer);      return () => {       socket.off(ACTIONS.ADD_PEER);     }   }, []);    useEffect(() => {     async function setRemoteMedia({peerID, sessionDescription: remoteDescription}) {       await peerConnections.current[peerID]?.setRemoteDescription(         new RTCSessionDescription(remoteDescription)       );        if (remoteDescription.type === 'offer') {         const answer = await peerConnections.current[peerID].createAnswer();          await peerConnections.current[peerID].setLocalDescription(answer);          socket.emit(ACTIONS.RELAY_SDP, {           peerID,           sessionDescription: answer,         });       }     }      socket.on(ACTIONS.SESSION_DESCRIPTION, setRemoteMedia)      return () => {       socket.off(ACTIONS.SESSION_DESCRIPTION);     }   }, []);    useEffect(() => {     socket.on(ACTIONS.ICE_CANDIDATE, ({peerID, iceCandidate}) => {       peerConnections.current[peerID]?.addIceCandidate(         new RTCIceCandidate(iceCandidate)       );     });      return () => {       socket.off(ACTIONS.ICE_CANDIDATE);     }   }, []);    useEffect(() => {     const handleRemovePeer = ({peerID}) => {       if (peerConnections.current[peerID]) {         peerConnections.current[peerID].close();       }        delete peerConnections.current[peerID];       delete peerMediaElements.current[peerID];        updateClients(list => list.filter(c => c !== peerID));     };      socket.on(ACTIONS.REMOVE_PEER, handleRemovePeer);      return () => {       socket.off(ACTIONS.REMOVE_PEER);     }   }, []);    useEffect(() => {     async function startCapture() {       localMediaStream.current = await navigator.mediaDevices.getUserMedia({         audio: true,         video: {           width: 1280,           height: 720,         }       });        addNewClient(LOCAL_VIDEO, () => {         const localVideoElement = peerMediaElements.current[LOCAL_VIDEO];          if (localVideoElement) {           localVideoElement.volume = 0;           localVideoElement.srcObject = localMediaStream.current;         }       });     }      startCapture()       .then(() => socket.emit(ACTIONS.JOIN, {room: roomID}))       .catch(e => console.error('Error getting userMedia:', e));      return () => {       localMediaStream.current.getTracks().forEach(track => track.stop());        socket.emit(ACTIONS.LEAVE);     };   }, [roomID]);    const provideMediaRef = useCallback((id, node) => {     peerMediaElements.current[id] = node;   }, []);    return {     clients,     provideMediaRef   }; }

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

import {useEffect, useRef, useCallback} from 'react'; import freeice from 'freeice'; import useStateWithCallback from './useStateWithCallback'; import socket from '../socket'; import ACTIONS from '../socket/actions';  export const LOCAL_VIDEO = 'LOCAL_VIDEO';   export default function useWebRTC(roomID) {   const [clients, updateClients] = useStateWithCallback([]);    const addNewClient = useCallback((newClient, cb) => {     updateClients(list => {       if (!list.includes(newClient)) {         return [...list, newClient]       }        return list;     }, cb);   }, [clients, updateClients]);    const peerConnections = useRef({});   const localMediaStream = useRef(null);   const peerMediaElements = useRef({     [LOCAL_VIDEO]: null,   });    useEffect(() => {     async function handleNewPeer({peerID, createOffer}) {       if (peerID in peerConnections.current) {         return console.warn(`Already connected to peer ${peerID}`);       }        peerConnections.current[peerID] = new RTCPeerConnection({         iceServers: freeice(),       });        peerConnections.current[peerID].onicecandidate = event => {         if (event.candidate) {           socket.emit(ACTIONS.RELAY_ICE, {             peerID,             iceCandidate: event.candidate,           });         }       }        let tracksNumber = 0;       peerConnections.current[peerID].ontrack = ({streams: [remoteStream]}) => {         tracksNumber++          if (tracksNumber === 2) { // video & audio tracks received           tracksNumber = 0;           addNewClient(peerID, () => {             if (peerMediaElements.current[peerID]) {               peerMediaElements.current[peerID].srcObject = remoteStream;             } else {               // FIX LONG RENDER IN CASE OF MANY CLIENTS               let settled = false;               const interval = setInterval(() => {                 if (peerMediaElements.current[peerID]) {                   peerMediaElements.current[peerID].srcObject = remoteStream;                   settled = true;                 }                  if (settled) {                   clearInterval(interval);                 }               }, 1000);             }           });         }       }        localMediaStream.current.getTracks().forEach(track => {         peerConnections.current[peerID].addTrack(track, localMediaStream.current);       });        if (createOffer) {         const offer = await peerConnections.current[peerID].createOffer();          await peerConnections.current[peerID].setLocalDescription(offer);          socket.emit(ACTIONS.RELAY_SDP, {           peerID,           sessionDescription: offer,         });       }     }      socket.on(ACTIONS.ADD_PEER, handleNewPeer);      return () => {       socket.off(ACTIONS.ADD_PEER);     }   }, []);    useEffect(() => {     async function setRemoteMedia({peerID, sessionDescription: remoteDescription}) {       await peerConnections.current[peerID]?.setRemoteDescription(         new RTCSessionDescription(remoteDescription)       );        if (remoteDescription.type === 'offer') {         const answer = await peerConnections.current[peerID].createAnswer();          await peerConnections.current[peerID].setLocalDescription(answer);          socket.emit(ACTIONS.RELAY_SDP, {           peerID,           sessionDescription: answer,         });       }     }      socket.on(ACTIONS.SESSION_DESCRIPTION, setRemoteMedia)      return () => {       socket.off(ACTIONS.SESSION_DESCRIPTION);     }   }, []);    useEffect(() => {     socket.on(ACTIONS.ICE_CANDIDATE, ({peerID, iceCandidate}) => {       peerConnections.current[peerID]?.addIceCandidate(         new RTCIceCandidate(iceCandidate)       );     });      return () => {       socket.off(ACTIONS.ICE_CANDIDATE);     }   }, []);    useEffect(() => {     const handleRemovePeer = ({peerID}) => {       if (peerConnections.current[peerID]) {         peerConnections.current[peerID].close();       }        delete peerConnections.current[peerID];       delete peerMediaElements.current[peerID];        updateClients(list => list.filter(c => c !== peerID));     };      socket.on(ACTIONS.REMOVE_PEER, handleRemovePeer);      return () => {       socket.off(ACTIONS.REMOVE_PEER);     }   }, []);    useEffect(() => {     async function startCapture() {       localMediaStream.current = await navigator.mediaDevices.getUserMedia({         audio: true,         video: {           width: 1280,           height: 720,         }       });        addNewClient(LOCAL_VIDEO, () => {         const localVideoElement = peerMediaElements.current[LOCAL_VIDEO];          if (localVideoElement) {           localVideoElement.volume = 0;           localVideoElement.srcObject = localMediaStream.current;         }       });     }      startCapture()       .then(() => socket.emit(ACTIONS.JOIN, {room: roomID}))       .catch(e => console.error('Error getting userMedia:', e));      return () => {       localMediaStream.current.getTracks().forEach(track => track.stop());        socket.emit(ACTIONS.LEAVE);     };   }, [roomID]);    const provideMediaRef = useCallback((id, node) => {     peerMediaElements.current[id] = node;   }, []);    return {     clients,     provideMediaRef   }; }

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

import {useParams} from 'react-router'; import useWebRTC, {LOCAL_VIDEO} from '../../hooks/useWebRTC';  function layout(clientsNumber = 1) {   const pairs = Array.from({length: clientsNumber})     .reduce((acc, next, index, arr) => {       if (index % 2 === 0) {         acc.push(arr.slice(index, index + 2));       }        return acc;     }, []);    const rowsNumber = pairs.length;   const height = `${100 / rowsNumber}%`;    return pairs.map((row, index, arr) => {      if (index === arr.length - 1 && row.length === 1) {       return [{         width: '100%',         height,       }];     }      return row.map(() => ({       width: '50%',       height,     }));   }).flat(); }  export default function Room() {   const {id: roomID} = useParams();   const {clients, provideMediaRef} = useWebRTC(roomID);   const videoLayout = layout(clients.length);    return (     <div style={{       display: 'flex',       alignItems: 'center',       justifyContent: 'center',       flexWrap: 'wrap',       height: '100vh',     }}>       {clients.map((clientID, index) => {         return (           <div key={clientID} style={videoLayout[index]} id={clientID}>             <video               width='100%'               height='100%'               ref={instance => {                 provideMediaRef(clientID, instance);               }}               autoPlay               playsInline               muted={clientID === LOCAL_VIDEO}             />           </div>         );       })}     </div>   ); }

Каждая картинка будет передаваться в том качестве, в котором я укажу, из-за чего такой чат имеет огромное преимущество, ведь он почти не сжимает изображение и передает его таким, каким оно поступило в answer или в offer.

Заключение второй части

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

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

https://github.com/DeverG3nt/video-chat-webrtc


ссылка на оригинал статьи https://habr.com/ru/post/653199/


Комментарии

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

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