Что-то не так с видео-вызовом (WebRTC, socket.io)
Добрый день! Мне нужно создать приложение для видео вызова на базе 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 Подскажите пожалуйста, что нужно исправить или добавить для полноценной работы видео связи? Спасибо всем за внимание. |
Проблема решена. Если что, обращайтесь.
|
Kiten, почему бы сразу не описать в чем была проблема и каким образом её удалось решить?
|
Причин было несколько.
Во-первых, при завершении сеанса WebRTC нужно закрыть peerConnection и остановить видеопоток. Также я использовал рефы для прямого доступа к объектам из разных функций. И наконец, использованная мной конструкция была устаревшей, хотя она приводится в официальной документации WebRTC. Более эффективный способ называется "Perfect negotiation pattern" и описан здесь: https://developer.mozilla.org/en-US/...ct_negotiation Вот работающий код: // MDN perfect negotiation pattern import React, { ChangeEvent, Fragment, useEffect, useRef, useState, } from "react"; import { Box, Card, CardContent, CardHeader, Snackbar, Stack, TextField, Typography, } from "@mui/material"; import { Socket, io } from "socket.io-client"; import { XButton1, XButton2 } from "./Buttons"; import { rtcConfig } from "../../rtcConfig"; interface Signal { operation: "offer" | "answer" | "ice" | null; source?: string; target?: string; data?: any; } const VITE_SOCK_API = import.meta.env.VITE_SOCK_API; const constraints = { audio: true, video: true }; export default function RoomContainer({ id }: { id: string }) { const partnerVideo = useRef<HTMLVideoElement>(null); const peerRef = useRef<RTCPeerConnection>(); const socketRef = useRef<Socket | null>(); const userStream = useRef<any | null>(); const [message, setMessage] = useState<string>(""); const [value, setValue] = useState<string>(""); useEffect(() => { navigator.mediaDevices.getUserMedia(constraints).then((stream) => { userStream.current = stream; socketRef.current = io(VITE_SOCK_API); socketRef.current.emit("join", id); socketRef.current.on(id, (arg: Signal) => { if (arg.operation === "offer") handleRecieveCall(arg); if (arg.operation === "answer") handleAnswer(arg); if (arg.operation === "ice") handleNewICECandidateMsg(arg); }); }); }, []); function handleRecieveCall(arg: Signal) { peerRef.current = createPeer(); const desc = new RTCSessionDescription(arg.data); peerRef.current .setRemoteDescription(desc) .then(() => { userStream.current .getTracks() .forEach((track: MediaStreamTrack) => peerRef.current?.addTrack(track, userStream.current) ); }) .then(() => { return peerRef.current?.createAnswer(); }) .then((answer) => { return peerRef.current?.setLocalDescription(answer); }) .then(() => { const payload: Signal = { operation: "answer", target: arg.source, source: id, data: peerRef.current?.localDescription, }; socketRef.current?.emit("server", payload); }); } function callUser() { peerRef.current = createPeer(); userStream.current .getTracks() .forEach((track: MediaStreamTrack) => peerRef.current?.addTrack(track, userStream.current) ); } function createPeer() { const peer = new RTCPeerConnection(rtcConfig); peer.onicecandidate = handleICECandidateEvent; peer.ontrack = handleTrackEvent; peer.onnegotiationneeded = () => handleNegotiationNeededEvent(); peer.oniceconnectionstatechange = handleICEConnectionStateChangeEvent; peer.onsignalingstatechange = handleSignalingStateChangeEvent; return peer; } function handleICEConnectionStateChangeEvent() { console.log("Ice connection state change") switch (peerRef.current?.iceConnectionState) { case "closed": case "failed": closeVideoCall(); break; } } function handleSignalingStateChangeEvent() { console.log("Signalling state change") switch (peerRef.current?.signalingState) { case "closed": closeVideoCall(); break; } } function handleAnswer(message: Signal) { const desc = new RTCSessionDescription(message.data); if (peerRef.current) peerRef.current.setRemoteDescription(desc).catch((e) => console.log(e)); } function handleICECandidateEvent(e: RTCPeerConnectionIceEvent) { if (e.candidate) { const payload: Signal = { operation: "ice", target: value, data: e.candidate, }; socketRef.current?.emit("server", payload); } } function handleNegotiationNeededEvent() { if (peerRef.current) peerRef.current .createOffer() .then((offer) => { return peerRef.current?.setLocalDescription(offer); }) .then(() => { const payload: Signal = { operation: "offer", target: value, source: id, data: peerRef.current?.localDescription, }; socketRef.current?.emit("server", payload); }) .catch((e) => console.log(e)); } function handleNewICECandidateMsg(incoming: Signal) { const candidate = new RTCIceCandidate(incoming.data); if (peerRef.current) peerRef.current.addIceCandidate(candidate).catch((e) => console.log(e)); } function handleTrackEvent(e: RTCTrackEvent) { if (partnerVideo.current) partnerVideo.current.srcObject = e.streams[0]; } function closeVideoCall() { if (peerRef.current) { peerRef.current.ontrack = null; peerRef.current.onicecandidate = null; peerRef.current.oniceconnectionstatechange = null; peerRef.current.onsignalingstatechange = null; peerRef.current.onicegatheringstatechange = null; peerRef.current.onnegotiationneeded = null; peerRef.current.close(); peerRef.current = undefined; } if (userStream.current) userStream.current .getTracks() .forEach(function (track: MediaStreamTrack) { track.stop(); }); userStream.current = null; if (partnerVideo.current) partnerVideo.current.srcObject = null; } 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) } /> <Stack direction="row" spacing={2}> <XButton1 onClick={async () => { callUser(); }} > Connect </XButton1> </Stack> <Box sx={{ width: "100%", pb: "65%", position: "relative", border: "1px solid #ddd", }} > <video ref={partnerVideo} 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> ); } |
Часовой пояс GMT +3, время: 10:53. |