Первая реализация себя в 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://habr.com/ru/post/653199/


Интересные статьи

Интересные статьи

Каждый мало-мальски компетентный project-менеджер если и не знаком детально, то хоть краем уха, но все таки слышал о системе "гибких" подходов к разработке программного обеспечения. Речь идет об "Agil...
В первой части (https://habr.com/ru/post/560356/) получилось разработать простенькую стековую виртуальную машину, которая умеет работу со стеком, арифметику с целыми числ...
В предыдущей статье мы подобрали все необходимые компоненты для сборки нашей самодельной ретроконсоли. Настало время вдохнуть в нее жизнь! В этом нам поможет RetroPie — о...
Сидение на необъявленном карантине можно провести с пользой, совершенствуя тело и разум. О первом мы, возможно, когда-нибудь поговорим, а пока предлагаем вам пищу для ума: подборку разноплановы...
Попробуем подключить Celery/RabbitMQ к нашему проекту. В качестве основы возьмем проект с Flask'ом. Celery займется вычислением случайного числа.