Причин было несколько.
Во-первых, при завершении сеанса
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>
);
}