В предыдущей статье туториала я описывал стандарты видеосвязи и сказал, что остановился на 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
Aidar87
Не по perfect negotiation
DeverGent Автор
Sorry, but this is my first try working with webRTC