В предыдущей статье туториала я описывал стандарты видеосвязи и сказал, что остановился на 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/
Добавить комментарий