Javascript.RU

Создать новую тему Ответ
 
Опции темы Искать в теме
  #1 (permalink)  
Старый 28.04.2023, 15:27
Аватар для Kiten
Интересующийся
Отправить личное сообщение для Kiten Посмотреть профиль Найти все сообщения от Kiten
 
Регистрация: 18.05.2018
Сообщений: 16

Что-то не так с видео-вызовом (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

Подскажите пожалуйста, что нужно исправить или добавить для полноценной работы видео связи?
Спасибо всем за внимание.
Ответить с цитированием
  #2 (permalink)  
Старый 30.04.2023, 19:56
Аватар для Kiten
Интересующийся
Отправить личное сообщение для Kiten Посмотреть профиль Найти все сообщения от Kiten
 
Регистрация: 18.05.2018
Сообщений: 16

Проблема решена. Если что, обращайтесь.
Ответить с цитированием
  #3 (permalink)  
Старый 30.04.2023, 22:37
Профессор
Отправить личное сообщение для Nexus Посмотреть профиль Найти все сообщения от Nexus
 
Регистрация: 04.12.2012
Сообщений: 3,734

Kiten, почему бы сразу не описать в чем была проблема и каким образом её удалось решить?
Ответить с цитированием
  #4 (permalink)  
Старый 09.05.2023, 06:54
Аватар для Kiten
Интересующийся
Отправить личное сообщение для Kiten Посмотреть профиль Найти все сообщения от Kiten
 
Регистрация: 18.05.2018
Сообщений: 16

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



Опции темы Искать в теме
Искать в теме:

Расширенный поиск


Похожие темы
Тема Автор Раздел Ответов Последнее сообщение
Возведение в степень по модулю. Что не так? Добрый_Серый_Волк Ваши сайты и скрипты 7 18.10.2019 18:03
Что в этом коде не так? nzbt Javascript под браузер 0 27.02.2019 15:27
Как отследить, что видео закончило проигрываться? main.c Элементы интерфейса 1 17.04.2015 20:57
Сайт торент видео онлайн (через браузер). nemo84 Ваши сайты и скрипты 1 18.05.2013 21:07
Посоветуйте новику, что я делаю не так danil-n2 Общие вопросы Javascript 5 26.04.2013 21:22