Добрый день! Мне нужно создать приложение для видео вызова на базе
WebRTC. Схема его работы следующая. На главной странице есть кнопка со ссылкой на страницу видео связи.
Id страницы — это строка из случайных символов. Далее клиент присоединяется к комнате, отправив запрос на сервер через
socket.io. Его
id также отображается на экране. Это приложение full-stack на базе
React,
Node.js,
Socket.io,
WebRTC,
TypeScript. Я все максимально упростил, чтобы сосредоточиться именно на технологии
WebRTC.
Чтобы осуществить вызов, нужно ввести
id другого клиента и нажать кнопку ниже. Мне нужно, чтобы при установлении соединения у каждого пользователя появлялось видео с устройства собеседника, и наоборот (как например при видео звонке WhatsApp). На каждом устройстве включается разрешение на использование микрофона и камеры.
В качестве сервера для обмена сигналами используется
socket.io на базе
node.js.
Проблема в том, что при осуществлении вызова, видео иногда отображается у обоих пользователей, иногда только на одном устройстве. Иногда на обоих концах нет видео. В общем, приложение работает нестабильно.
Вот код сервера для обмена сигналами:
import { io } from ".";
interface Signal {
operation: 'join' | 'offer' | 'answer' | 'ice';
source?: string;
target: string;
data: any;
}
io.on("connection", (socket) => {
socket.on("server", (arg: Signal) => {
console.log(arg)
if(arg.operation==='join') {
socket.join(arg.target);
}
if(arg.operation==='offer' || arg.operation==='answer' || arg.operation==='ice') {
console.log(arg.operation);
io.to(arg.target).emit(arg.target, arg)
}
});
});
Главная страница клиента. При клике на кнопку переходим в комнату (роут со случайным id):
import React from "react";
import { XButton2 } from "../components/Buttons";
import { useNavigate } from "react-router-dom";
const makeId = (length: number): string => {
let result = "";
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
export default function Main() {
const navigate = useNavigate();
function create() {
const id = makeId(4);
navigate(`/room/${id}`);
}
return <XButton2 onClick={create}>Create room</XButton2>;
}
Страница комнаты:
import React, { Fragment } from "react";
import { IconButton } from "@mui/material";
import { AiOutlineLeft } from "react-icons/ai";
import { useNavigate, useParams } from "react-router-dom";
import RoomContainer from "../components/RoomContainer";
export default function Room() {
const navigate = useNavigate();
const { id } = useParams();
return (
<Fragment>
<IconButton
sx={{ mb: 3, fontSize: "35px", border: "1px solid #ddd" }}
onClick={() => navigate("/")}
>
<AiOutlineLeft />
</IconButton>
{id ? <RoomContainer id={id} /> : null}
</Fragment>
);
}
Это компонент, в котором должно выполняться видео соединение:
import React, {
ChangeEvent,
Fragment,
useEffect,
useRef,
useState,
} from "react";
import {
Box,
Card,
CardContent,
CardHeader,
Snackbar,
Stack,
TextField,
Typography,
} from "@mui/material";
import { io } from "socket.io-client";
import { XButton1 } from "./Buttons";
import { rtcConfig } from "../../rtcConfig";
interface Signal {
operation: "join" | "offer" | "answer" | "ice";
source?: string;
target: string;
data?: any;
}
const VITE_SOCK_API = import.meta.env.VITE_SOCK_API;
const socket = io(VITE_SOCK_API);
const peerConnection = new RTCPeerConnection(rtcConfig);
export default function RoomContainer({ id }: { id: string }) {
const remoteRef = useRef<HTMLVideoElement>(null);
const [message, setMessage] = useState<string>("");
const [value, setValue] = useState<string>("");
async function call() {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
const mySignal: Signal = {
operation: "offer",
source: id,
target: value,
data: offer,
};
socket.emit("server", mySignal);
}
useEffect(() => {
// VIDEO!!!
navigator.mediaDevices
.getUserMedia({
video: true,
audio: true,
})
.then((stream) => {
// Join
const mySignal: Signal = { operation: "join", target: id };
socket.emit("server", mySignal);
stream.getTracks().forEach((track) => {
peerConnection.addTrack(track, stream);
});
// Incoming sockets
socket.on(id, async (arg: Signal) => {
// Incoming offer
if (arg.operation === "offer" && arg.source) {
peerConnection.setRemoteDescription(
new RTCSessionDescription(arg.data)
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
const mySignal: Signal = {
operation: "answer",
source: id,
target: arg.source,
data: answer,
};
socket.emit("server", mySignal);
}
// Recieve an answer
if (arg.operation === "answer") {
const remoteDesc = new RTCSessionDescription(arg.data);
await peerConnection.setRemoteDescription(remoteDesc);
}
// Ice candidate
if (arg.operation === "ice") {
try {
const candidate = new RTCIceCandidate(arg.data); // ??????
await peerConnection.addIceCandidate(candidate);
} catch (e) {
console.error("Error adding received ice candidate", e);
}
}
});
});
}, []);
peerConnection.addEventListener("track", async (event) => {
const [remoteStream] = event.streams;
if (remoteRef.current) remoteRef.current.srcObject = remoteStream;
});
// Listen for local ICE candidates on the local RTCPeerConnection
peerConnection.addEventListener("icecandidate", (event) => {
console.log(event.candidate);
if (event.candidate) {
const mySignal: Signal = {
operation: "ice",
source: id,
target: value,
data: event.candidate,
};
socket.emit("server", mySignal);
}
});
// Listen for connectionstatechange on the local RTCPeerConnection
peerConnection.addEventListener("connectionstatechange", (event) => {
if (peerConnection.connectionState === "connected") {
setMessage("Peers connected!");
}
});
return (
<Fragment>
<Card>
<CardHeader
title="Connect and open media channel"
sx={{ borderBottom: "1px solid #ddd" }}
/>
<CardContent>
<Stack spacing={2}>
<Typography
variant="h5"
sx={{
cursor: "pointer",
border: "1px solid #ddd",
borderRadius: "5px",
p: 2,
}}
onClick={() => {
if (id) {
navigator.clipboard.writeText(id);
setMessage("Copied");
}
}}
>
{id}
</Typography>
<TextField
label="Connect other client"
variant="outlined"
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setValue(e.target.value)
}
/>
<XButton1
onClick={async () => {
// offer
if (id && value) call();
}}
>
Connect
</XButton1>
<Box sx={{ width: "100%", pb: "65%", position: "relative", border: '1px solid #ddd' }}>
<video
ref={remoteRef}
autoPlay
playsInline
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
}}
/>
</Box>
</Stack>
</CardContent>
</Card>
<Snackbar
open={Boolean(message.length)}
autoHideDuration={3000}
onClose={() => setMessage("")}
message={message}
/>
</Fragment>
);
}
Также я заметил, что во время установления соединения, событие по раздаче кандидатов срабатывает очень много раз (я включил прослушивание через
console.log).
Полный код также доступен в этом репозитории:
https://github.com/narzantaria/webrtc-app
Подскажите пожалуйста, что нужно исправить или добавить для полноценной работы видео связи?
Спасибо всем за внимание.