vue3实现WebRTC实战1v1通话

本文介绍了如何使用WebRTC进行1对1视频通话,包括创建RTCPeerConnection、配置Coturn服务器、使用WebSocket和ICE协议,以及前端Vue实现的代码示例。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

webRTC

利用webrtc进行1对1视频通话,也就是p2p点对点连接。

核心技术点包括:websocket、webRTC、coturn服务器等
前端io库为socket.io-client

客户端

创建RTCPeerConnection对象加入coturn服务器地址,STUN服务器用于获取公网IP地址,TURN服务器用于穿透防火墙

pc.value = new RTCPeerConnection({
        iceTransportPolicy: "relay", //all: 将使用所有网络接口类型,包括relay(中继)和srflx(对称NAT)
        iceServers: [
          {
            urls: "stun:1.15.14.164:3478",
          },
          {
            urls: [
              "turn:1.15.14.164:3478?transport=udp",
              "turn:1.15.14.164:3478?transport=tcp",
            ],
            username: "webrtc",
            credential: "webrtc666",
          },
        ],
      });

coturn服务器配置

安装coturn

yum install coturn
cd /etc/coturn/
vi turnserver.conf

加入配置

listening-ip=1.15.14.164
listening-port=3478
user=webrtc:webrtc666
realm=car-service.lichees.top

查看状态

systemctl start coturn # 启动
systemctl stop coturn # 停止
systemctl status coturn # 查看状态

测试是否能连通

点击前往

前端代码

index.vue

<template>
  <div>
    <div class="videos">
      <div class="local-video">
        <video ref="localVideo" autoplay muted playsinline="">本地窗口</video>
      </div>
      <div class="remote-video">
        <video ref="remoteVideo" autoplay playsinline="">远程窗口</video>
      </div>
    </div>
    <div class="setting">
      <van-icon name="wap-nav" color="#fff" @click="show = !show" />
    </div>

    <van-popup v-model:show="show">
      <div class="room">
        <van-field v-model="roomId" label="房间号" placeholder="请输入房间号" />
        <div class="buttons">
          <van-button type="primary" @click="remove">离开</van-button>
          <van-button type="success" @click="join">加入</van-button>
        </div>
      </div>
    </van-popup>
  </div>
</template>

<script>
import { onMounted, onUnmounted, ref } from "vue";
import { io } from "socket.io-client";
import { Toast } from "vant";

export default {
  setup() {
    const show = ref(false); // 是否显示弹窗
    const roomId = ref("");
    const localVideo = ref(null);
    const remoteVideo = ref(null);
    const localStream = ref(null); // 本地流
    const remoteStream = ref(null); // 远程流
    const socket = io("wss://car-service.lichees.top/");
    // const socket = io("ws://localhost:8989/");
    const pc = ref(null); // RTCPeerConnection实例

    const join = async () => {
      show.value = false;
      if (!roomId.value) {
        Toast("请输入房间号");
        return;
      }
      try {
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: true,
        });
        localVideo.value.srcObject = stream; // 将视频流设置为video元素的源
        localStream.value = stream;
        localVideo.value.play(); // 播放视频
        doJoin(roomId.value);
      } catch (err) {
        Toast("无法访问用户媒体设备");
        console.error(err); // 无法访问用户媒体设备
      }
    };
    const remove = () => {
      show.value = false;
      try {
        localStream.value.getTracks().forEach((track) => track.stop());
        localVideo.value.srcObject = null;
        doLeave(roomId.value);
      } catch (err) {
        console.error("localStream错误", err);
      }
    };

    // 信令
    const SIGNAL_TYPE_JOIN = "join"; // 加入房间
    const SIGNAL_TYPE_RESP_JOIN = "resp_join"; // 加入房间响应
    const SIGNAL_TYPE_LEAVE = "leave"; // 离开房间
    const SIGNAL_TYPE_NEW_PEER = "new_peer"; // 新用户加入房间
    const SIGNAL_TYPE_PEER_LEAVE = "peer_leave"; // 用户离开房间
    const SIGNAL_TYPE_OFFER = "offer";
    const SIGNAL_TYPE_ANSWER = "answer";
    const SIGNAL_TYPE_CANDIDATE = "candidate";
    const loaclUid = ref(Math.random().toString(36).substr(2));
    const remoteUid = ref("");
    const intervalId = ref(null); // 心跳包定时器

    const doJoin = (roomId) => {
      const jsonMsg = {
        cmd: SIGNAL_TYPE_JOIN,
        roomId: roomId,
        uid: loaclUid.value,
      };
      socket.emit("text", JSON.stringify(jsonMsg));
      console.log(new Date().toLocaleTimeString(), "发送加入房间请求", jsonMsg);
    };
    const doLeave = (roomId) => {
      const jsonMsg = {
        cmd: SIGNAL_TYPE_LEAVE,
        roomId: roomId,
        uid: loaclUid.value,
      };
      socket.emit("text", JSON.stringify(jsonMsg));
      console.log(new Date().toLocaleTimeString(), "发送离开房间请求", jsonMsg);
    };

    // 创建对等连接
    const createPeerConnection = (data) => {
      console.log(
        new Date().toLocaleTimeString(),
        "执行createPeerConnection",
        data
      );
      remoteUid.value = data;
      pc.value = new RTCPeerConnection({
        iceTransportPolicy: "relay", //all: 将使用所有网络接口类型,包括relay(中继)和srflx(对称NAT)
        iceServers: [
          {
            urls: "stun:1.15.14.164:3478",
          },
          {
            urls: [
              "turn:1.15.14.164:3478?transport=udp",
              "turn:1.15.14.164:3478?transport=tcp",
            ],
            username: "webrtc",
            credential: "webrtc666",
          },
        ],
      });

      //  ICE协商时收集本地网络接口的信息,并将其打包成一个或多个 "候选者"
      pc.value.onicecandidate = (event) => {
        if (event.candidate) {
          const jsonMsg = {
            cmd: SIGNAL_TYPE_CANDIDATE,
            roomId: roomId.value,
            uid: loaclUid.value,
            remoteUid: remoteUid.value,
            msg: JSON.stringify(event.candidate),
          };
          socket.emit("text", JSON.stringify(jsonMsg));
          console.log(
            new Date().toLocaleTimeString(),
            "发送ICE候选者",
            jsonMsg
          );
        } else {
          console.warn("没有ICE候选者");
        }
      };

      // 一旦远程流到达RTCPeerConnection,将触发此事件
      pc.value.ontrack = (event) => {
        remoteStream.value = event.streams[0];
        remoteVideo.value.srcObject = remoteStream.value;
      };
      // 一旦本地流到达RTCPeerConnection,将触发此事件
      localStream.value.getTracks().forEach((track) => {
        pc.value.addTrack(track, localStream.value);
      });

      // 创建数据通道(心跳)
      const channel = pc.value.createDataChannel("heartbeat", {
        ordered: false, // 不保证数据包顺序
        maxRetransmits: 0, // 不限制重传次数
      }); // 创建数据通道

      channel.onopen = () => {
        intervalId.value = setInterval(() => {
          channel.send(new Uint8Array([0]));
        }, 5000); //定时发送心跳
      };
      channel.onclose = () => {
        if (intervalId.value) clearInterval(intervalId.value);
      };
    };

    const doOffer = (data) => {
      console.log(new Date().toLocaleTimeString(), "执行doOffer", data);
      remoteUid.value = data;
      //创建RTCPeerConnection对象
      if (pc.value == null) {
        createPeerConnection(remoteUid.value);
      }
      pc.value
        .createOffer()
        .then((desc) => {
          pc.value
            .setLocalDescription(desc)
            .then(() => {
              const jsonMsg = {
                cmd: SIGNAL_TYPE_OFFER,
                roomId: roomId.value,
                uid: loaclUid.value,
                remoteUid: remoteUid.value,
                msg: JSON.stringify(desc),
              };
              socket.emit("text", JSON.stringify(jsonMsg));
              console.log(
                new Date().toLocaleTimeString(),
                "发送OFFER",
                jsonMsg
              );
            })
            .catch((err) => {
              console.error("setLocalDescription报错", err);
            });
        })
        .catch((err) => {
          console.error("createOffer报错", err);
        });
    };

    const doAnswer = (data) => {
      console.log(new Date().toLocaleTimeString(), "执行doAnswer", data);
      remoteUid.value = data;
      if (pc.value == null) {
        createPeerConnection(remoteUid.value);
      }
      pc.value
        .createAnswer()
        .then((desc) => {
          pc.value
            .setLocalDescription(desc)
            .then(() => {
              const jsonMsg = {
                cmd: SIGNAL_TYPE_ANSWER,
                roomId: roomId.value,
                uid: loaclUid.value,
                remoteUid: remoteUid.value,
                msg: JSON.stringify(desc),
              };
              socket.emit("text", JSON.stringify(jsonMsg));
              console.log(
                new Date().toLocaleTimeString(),
                "发送ANSWER",
                jsonMsg
              );
            })
            .catch((err) => {
              console.error("setLocalDescription报错", err);
            });
        })
        .catch((err) => {
          console.error("createAnswer报错", err);
        });
    };

    const handleRemoteOffer = (message) => {
      console.log(new Date().toLocaleTimeString(), "处理远程offer", message);
      if (pc.value == null) {
        createPeerConnection(message.remoteUid);
      }
      const desc = new RTCSessionDescription(JSON.parse(message.msg));
      pc.value.setRemoteDescription(desc).then(() => {
        console.info("处理远程setRemoteDescription成功");
      });
      doAnswer(message.uid);
    };

    const handleRemoteAnswer = (message) => {
      console.log(new Date().toLocaleTimeString(), "处理远程answer", message);
      if (pc.value == null) {
        createPeerConnection(message.remoteUid);
      }
      const desc = new RTCSessionDescription(JSON.parse(message.msg));
      pc.value.setRemoteDescription(desc).then(() => {
        console.info("处理远程回答setRemoteDescription成功");
      });
    };

    const handleCandidate = (message) => {
      console.log(
        new Date().toLocaleTimeString(),
        "处理远程ICE候选者",
        message
      );
      const candidate = JSON.parse(message.msg);
      pc.value.addIceCandidate(candidate).catch((err) => {
        console.error("添加ICE失败", err);
      });
    };

    onMounted(() => {
      // 设置全屏
      // document.documentElement.requestFullscreen();
      socket.on("text", (data) => {
        const jsonMsg = JSON.parse(data);
        switch (jsonMsg.cmd) {
          case SIGNAL_TYPE_RESP_JOIN:
            if (jsonMsg.result == "fail") {
              Toast("加入房间失败");
              return;
            } else {
              Toast("加入房间成功");
            }
            console.log(
              new Date().toLocaleTimeString(),
              "发送加入房间请求响应结果",
              jsonMsg.result
            );
            break;
          case SIGNAL_TYPE_NEW_PEER:
            remoteUid.value = jsonMsg.remoteUid;
            console.log(
              new Date().toLocaleTimeString(),
              "有人加入房间",
              jsonMsg
            );
            doOffer(jsonMsg.remoteUid);
            break;

          case SIGNAL_TYPE_LEAVE:
            console.log(
              new Date().toLocaleTimeString(),
              "离开房间响应结果",
              jsonMsg
            );
            if (jsonMsg.result == "fail") {
              Toast(jsonMsg.msg);
              return;
            } else {
              Toast("离开房间成功");
              // 离开成功后断开连接
              pc.value.close();
              pc.value = null;
              remoteVideo.value.srcObject = null; // 关闭远程视频
            }
            break;
          case SIGNAL_TYPE_PEER_LEAVE:
            console.log(
              new Date().toLocaleTimeString(),
              "有人离开房间",
              jsonMsg
            );
            remoteVideo.value.srcObject = null;
            Toast(`用户${jsonMsg.remoteUid}离开房间`);
            pc.value.close();
            pc.value = null;
            remoteVideo.value.srcObject = null;
            break;

          case SIGNAL_TYPE_OFFER:
            handleRemoteOffer(jsonMsg);
            break;
          case SIGNAL_TYPE_ANSWER:
            handleRemoteAnswer(jsonMsg);
            break;
          case SIGNAL_TYPE_CANDIDATE:
            handleCandidate(jsonMsg);
            break;

          default:
            break;
        }
      });
    });

    onUnmounted(() => {
      remove();
    });
    return {
      show,
      roomId,
      localVideo,
      remoteVideo,
      join,
      remove,
    };
  },
};
</script>
<style lang="less" scoped>
.setting {
  position: fixed;
  padding: 8px;
  border-radius: 50%;
  background: var(--brown);
  top: 32px;
  right: 16px;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.room {
  padding: 8px;
  min-width: 300px;
  .buttons {
    display: flex;
    justify-content: space-around;
    align-items: center;
    margin-top: 16px;
  }
}
.videos {
  video {
    width: 100%;
    transform: rotateY(180deg);
  }
  .local-video {
    max-width: 100%;
    border: 2px solid #15ff00;
    position: relative;
  }
  .local-video::before {
    position: absolute;
    content: "本地视频";
    top: 0;
    left: 0;
    color: #15ff00;
    width: 100%;
    height: 14px;
  }
  .remote-video {
    max-width: 100%;
    border: 2px solid #ff0000;
    position: relative;
  }
  .remote-video::before {
    position: absolute;
    content: "远程视频";
    top: 0;
    left: 0;
    color: #ff0000;
    width: 100%;
    height: 14px;
  }
}
</style>

后端代码

下载资源后查看

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sea_lichee

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值