webrtc之一对一音视频通话

556 阅读8分钟

上一小节写了如何实现一个简单的信令服务器,麻雀虽小五脏俱全,也不是不能用哈。回想一下,媒体协商、SDP和ICE交换、信令服务器都有了(忘了请往回看看),现在也该动手实现一个完整的、简单的webrtc音视频通话了。

这里有几个点提前声明:

  1. 使用的是vue3框架,用的是socket.io-client
  2. 实现的是内网环境下的音视频通话,即不考虑网络环境问题,默认是同一环境下(后续会介绍使用STUNTURN来实现不同环境下连接)
  3. 仅实现 p2p 通话,多人模式也是后续介绍(其实也就是多个 p2p互相连接罢了)
  4. 需要用到上一小节实现的信令服务器

现在来动手实现了。先看看html部分,这个简单,大致看看:

<div v-for="item in roomUserList" :key="item.userId" class="userList">
  {{ "用户" + item.nickName }}
  <el-tag size="small" v-if="userInfo.userId === item.userId"
    >本人</el-tag
  >
  <template v-if="userInfo.userId !== item.userId">
    <el-button size="small" type="primary" @click="call(item)">
      {{ callSate[item.userId] || "通话" }}
    </el-button>
    <el-button
      v-if="callSate[item.userId] === '通话中'"
      size="small"
      type="primary"
      @click="handleHangUp(item)"
    >
      挂断
    </el-button>
  </template>
</div>

<video ref="videoRef" autoplay muted></video>
<video ref="remoteVideoRef" autoplay muted></video>

代码很简单,前面主要就是遍历roomUserList展示出当前在线人员,然后可以与在线且没有正在通话的人进行音视频通话。后面两个video标签就是用来展示自己和对方的视频。样式的话,自由发挥了。

注意,自己不能和自己通话,要排除掉。正在通话中的也要排除掉。

逻辑实现

再来看看重点部分,具体的逻辑实现如下:

  1. 用户进入到页面后,会自动生成一个用户id,其他的如用户名等信息可以是自己填写,也可以自动生成。注意用户id必须确保唯一性,可以由后端来给出。当然,这节就简单实现,前端用nanoid库来生成。
  2. 进入页面后,自动连接后端socket,然后监听一系列事件,比如roomUserList事件来获取在线人员列表,joinleave事件,offerice事件等。
  3. 连接后端socket成功后,会后端会触发roomUserList事件,并返回在线人员列表,获取到后就将他们显示出来。
  4. 随机选择一个在线人员,点击通话按钮。注意这时并没有就直接发起媒体协商,而是要询问一下对方是不是愿意接受你的通话,如果接受再进行媒体协商,不能接受只能放弃了,连接了也没有用。这里就可以再添加两个监听事件,在主动发起方触发preCall事件,告知对方想要进行通话。接受方监听preCall事件,做出选择后触发backCall事件告知发起方是否接受,主动发再监听backCall事件来判断对方是否接受了,再考虑是否要发起媒体协商。
  5. 对方接受之后,发起方和接受方都开始初始化本地摄像头,将本地视频显示在videoRef中。同时,发起方会初始化PeerConnection,并且触发offer事件,将offer发送给接受方。接受方也初始化一个PeerConnection,监听offer事件,将接收到的offer保存到本地,然后生成一个answer,然后再触发answer事件。主动方监听answer事件,接收到answer并保存到本地,然后双方开始ice收集过程,都监听ice事件,直到完成ice收集,建立起连接进行音视频通话。
  6. 接下来就是要捕获错误,在音视频通话过程中出现了异常要及时捕获到。这里就可以监听PeerConnectiononconnectionstatechange事件,在这个事件中判断peer的连接状态变化,并给出相应的提示。还有一个peerError对象,不过这个没有在文档中看到demo示例,所以就不展开了,想深入的可以看这里
  7. 再然后就是终止连接了,两方的任意一方都可以随意终止连接,只需要调用PeerConnectionclose事件即可。然后触发hangUp事件,通知对方已经终止了连接。接受方监听hangUp,然后也调用close事件,并且进行一系列销毁事件。到这里就算是完成了一整个音视频通话的流程了。

代码实现

下面看具体代码,比较长:

const PeerConnection =
  window.RTCPeerConnection ||
  window.mozRTCPeerConnection ||
  window.webkitRTCPeerConnection;

// 处理报错
const handleError = (error) => {
  console.error(
    "navigator.MediaDevices.getUserMedia error: ",
    error.message,
    error.name
  );
};

// 设置本地视频源
const setDomVideoStream = async (newStream) => {
  const dom = videoRef.value;
  const stream = dom.srcObject;

  // 如果媒体流已经存在 则停止
  if (stream) {
    stream.getAudioTracks().forEach((e) => {
      stream.removeTrack(e);
    });
    stream.getVideoTracks().forEach((e) => {
      stream.removeTrack(e);
    });
  }
  dom.srcObject = newStream;
};

// 设置远程视频和音频
const setRemoteDomVideo = (track) => {
  if (track.kind === "audio") {
    const audioDom = remoteAudioRef.value;
    const stream = audioDom.srcObject;
    let newStream;
    if (stream) {
      stream.getTracks().forEach((track) => track.stop());
      newStream = new MediaStream([track]);
      audioDom.srcObject = newStream;
    } else {
      // 不存在,则创建并添加
      newStream = new MediaStream();
      newStream.addTrack(track);
      audioDom.srcObject = newStream;
    }
    remoteAudioSteam.value = newStream;
  } else if (track.kind === "video") {
    const videoDom = remoteVideoRef.value;
    const stream = videoDom.srcObject;
    let newStream;
    if (stream) {
      stream.getTracks().forEach((track) => track.stop());
      newStream = new MediaStream([track]);
      videoDom.srcObject = newStream;
    } else {
      // 不存在,则创建并添加
      newStream = new MediaStream();
      newStream.addTrack(track);
      videoDom.srcObject = newStream;
    }

    remoteVideoStream.value = newStream;
  }
};

// 获取设备的媒体流
const getLocalUserMedia = async (constraints) => {
  return await navigator.mediaDevices
    .getUserMedia(constraints)
    .catch(handleError);
};

// 初始化
const init = async (userId, roomId, nickName) => {
  Object.assign(userInfo, {
    userId,
    nickName,
    roomId,
  });

  // 初始化本地媒体信息
  localStream.value = await getLocalUserMedia({
    video: true,
    audio: true,
  });

  // 本地dom渲染  必须渲染完成后才能设置
  await setDomVideoStream(localStream.value);

  // 初始化socket
  linkSocket.value = io(serverUrl, {
    reconnectionDelayMax: 10000,
    transports: ["websocket"],
    query: {
      userId,
      roomId,
      nickName,
    },
  });

  linkSocket.value.on("connect", () => {
    console.log("链接成功");
  });
  linkSocket.value.on("error", (e) => {
    console.log("错误", e);
  });
  linkSocket.value.on("roomUserList", (data) => {
    roomUserList.value = data;

    console.log("房间用户列表", roomUserList.value);
  });
  linkSocket.value.on("msg", async (e) => {
    const handler = eventHandlers[e.type];
    if (handler) {
      await handler(e);
    }
  });
};

// 各种Msg事件
const eventHandlers = {
  join: async () => {
    const params = {
      roomId: props.roomId,
    };
    linkSocket.value.emit("roomUserList", params);
  },
  leave: async () => {
    const params = {
      roomId: props.roomId,
    };
    linkSocket.value.emit("roomUserList", params);
  },
  offer: async (e) => {
    const { targetUserId, userId, offer } = e?.data;
    await onRemoteOffer(userId, offer, targetUserId);
  },
  answer: async (e) => {
    const { userId, answer, targetUserId } = e?.data;
    await onRemoteAnswer(userId, answer, targetUserId);
  },
  candidate: async (e) => {
    const { userId, candidate } = e?.data;
    await onCandiDate(userId, candidate);
  },
  sendFile: (e) => {
    const { userId, content, targetUserId, fileType } = e?.data;
    onSendFile(userId, content, targetUserId, fileType);
  },
  receiveFile: (e) => {
    const { userId, content, targetUserId, type } = e?.data;
    onReceiveFile(userId, content, targetUserId, type);
  },
  preCall: (e) => {
    const { userId, targetUserId, type } = e?.data;
    onPreCall(userId, targetUserId, type);
  },
  backCall: async (e) => {
    const { userId, targetUserId, type } = e?.data;
    await onBackCall(userId, targetUserId, type);
  },
  hangUp: (e) => {
    const { userId, targetUserId } = e?.data;
    onHangUp(userId, targetUserId);
  },
  callInfo: (e) => {
    onSetCallInfo(e?.data);
  },
  hanUpCallInfo: (e) => {
    onHangUpCallInfo(e?.data);
  },
  micStatusChange: (e) => {
    const { userId, targetUserId, type } = e?.data;
    onChangeMic(userId, targetUserId, type);
  },
  webcamStatusChange: (e) => {
    const { userId, targetUserId, type } = e?.data;
    onChangeWebcam(userId, targetUserId, type);
  },
};

// 发出 预呼叫
const call = async (item) => {
  // 本人正在通话中 则提示
  if (
    callInfoList.includes(item.userId) ||
    callInfoList.includes(props.userId)
  ) {
    ElMessage({
      type: "error",
      message: `正在通话中,不能重复呼叫`,
    });
    return;
  }

  linkSocket.value.emit("preCall", {
    userId: props.userId,
    targetUserId: item.userId,
    content: "你小子,我想给你打个视频电话",
  });
  remoteUserId.value = item.userId;
};

// 接收到 预呼叫
const onPreCall = (fromUserId, targetUserId) => {
  if (targetUserId !== props.userId) return;

  ElMessageBox.confirm(`是否接受来自${fromUserId}的视频呼叫`, "info", {
    confirmButtonText: "接受",
    cancelButtonText: "拒绝",
    type: "info",
    center: true,
  })
    .then(() => {
      callSate[fromUserId] = "连接中";
      callerId = fromUserId;
      calleeId = targetUserId;
      linkSocket.value.emit("backCall", {
        userId: targetUserId,
        targetUserId: fromUserId,
        content: "我接受了你的视频呼叫",
        type: "OK",
      });
    })
    .catch(() => {
      callerId = "";
      calleeId = "";
      callSate[fromUserId] = "通话";
      linkSocket.value.emit("backCall", {
        userId: targetUserId,
        targetUserId: fromUserId,
        content: "那个吊毛拒绝了你的视频呼叫",
        type: "NO",
      });
    });
};

// 返回呼叫
const onBackCall = async (fromUserId, targetUserId, type) => {
  if (targetUserId !== props.userId) return; // 不是自己
  if (type === "OK") {
    // 初始话呼叫者信息
    callSate[fromUserId] = "连接中";
    callerId = targetUserId;
    calleeId = fromUserId;
    await initCallerInfo(callerId, calleeId);
  } else {
    callerId = "";
    calleeId = "";
    callSate[fromUserId] = "已拒绝";
    ElMessage.success(`拒绝了来自${fromUserId}的视频呼叫`);
  }
};

// 初始化呼叫者信息
const initCallerInfo = async (userId, targetUserId) => {
  const pcKey = userId + "-" + targetUserId;
  let pc = RtcPcMaps.get(pcKey);
  if (!pc) {
    pc = new PeerConnection();
    RtcPcMaps.set(pcKey, pc);
  }

  localRtcPc.value = pc;

  // 添加轨道
  for (const track of localStream.value.getTracks()) {
    const sender = pc.getSenders().find((s) => s.track === track);
    if (sender) {
      // 如果发送器已经存在,则更新发送器的轨道
      sender.replaceTrack(track);
    } else {
      // 否则,添加新的轨道
      pc.addTrack(track, localStream.value);
    }
  }

  // 触发监听
  onPcEvent(pc, userId, targetUserId);

  // 创建offer
  const offer = await pc.createOffer({ iceRestart: true });

  // 设置offer为本地描述
  await pc.setLocalDescription(offer);

  // 发送offer给被呼叫者
  const params = {
    userId,
    targetUserId,
    offer,
  };
  linkSocket.value.emit("offer", params);
};

// 挂断信息
const handleHangUp = (item) => {
  const pcKey = callerId + "-" + calleeId;
  const pc = RtcPcMaps.get(pcKey);

  pc.close();
  RtcPcMaps.delete(pcKey);

  remoteVideoRef.value.srcObject = null;
  remoteVideo.value = false;
  localRtcPc.value = null;
  _hark.stop();
  ElMessage.success(`挂断了与${item.nickName}的视频呼叫`);

  linkSocket.value.emit("hangUp", {
    userId: props.userId,
    targetUserId: item.userId,
    callerId,
    content: "我挂了啊",
    type: "NO",
  });
};

// 处理挂断
const onHangUp = (fromUserId, targetUserId) => {
  if (targetUserId !== props.userId) return; // 不是自己

  const pcKey = callerId + "-" + calleeId;
  const pc = RtcPcMaps.get(pcKey);

  pc.close();
  RtcPcMaps.delete(pcKey);
  remoteVideo.value = false;
  localRtcPc.value = null;
  _hark.stop();

  remoteVideoRef.value.srcObject = null;
};

// 处理远端offer
const onRemoteOffer = async (fromUserId, offer, targetUserId) => {
  const localUserId = props.userId;

  remoteUserId.value = fromUserId;

  // targetUserId 为被呼叫端的userId 不等于 userId 说明呼叫的不是自己 直接返回
  if (targetUserId !== localUserId) return;

  const pcKey = fromUserId + "-" + localUserId;
  const pc = new PeerConnection();
  RtcPcMaps.set(pcKey, pc);
  onPcEvent(pc, localUserId, fromUserId);

  localRtcPc.value = pc;

  for (const track of localStream.value.getTracks()) {
    const sender = pc.getSenders().find((s) => s.track === track);
    if (sender) {
      // 如果发送器已经存在,则更新发送器的轨道
      sender.replaceTrack(track);
    } else {
      // 否则,添加新的轨道
      pc.addTrack(track, localStream.value);
    }
  }

  await pc.setRemoteDescription(new RTCSessionDescription(offer)); // 保存远端发送给自己的信令

  const answer = await pc.createAnswer(); // 创建应答

  await pc.setLocalDescription(new RTCSessionDescription(answer)); // 保存应答
  const params = {
    userId: localUserId,
    targetUserId: fromUserId,
    answer,
  };
  linkSocket.value.emit("answer", params);
};

// 处理远端answer
const onRemoteAnswer = async (fromUserId, answer) => {
  const localUserId = props.userId;
  const pcKey = localUserId + "-" + fromUserId;

  const pc = RtcPcMaps.get(pcKey);
  await pc.setRemoteDescription(new RTCSessionDescription(answer));
};

// 处理 ice 候选信息
const onCandiDate = async (fromUserId, candidate) => {
  const localUserId = props.userId;
  const pcKey = localUserId + "-" + fromUserId;

  let pc = RtcPcMaps.get(pcKey);
  if (pc) {
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
  }
};

const onPcEvent = (pc, userId, targetUserId) => {

  // 监听远程媒体轨道即远端音视频信息
  pc.ontrack = (e) => {
    setRemoteDomVideo(e.track);
    console.log(e, "e--------------");
  };

  // 需要协商新的连接时会被触发  例如在添加新的数据流或者移除数据流时
  pc.onnegotiationneeded = (e) => {
    console.log("需要协商新的连接", e);
  };

  pc.oniceconnectionstatechange = (e) => {
    if (pc.iceConnectionState === "checking") {
      console.log("ice正在收集中...");
    }
    if (
      pc.iceConnectionState === "connected" ||
      pc.iceConnectionState === "completed"
    ) {
      console.log("ice收集完成");
    }
  };

  pc.onconnectionstatechange = (e) => {
    if (pc.connectionState === "connected") {
      console.log("连接成功");
      callSate[targetUserId] = "通话中";
      remoteVideo.value = true;
      // 发送呼叫信息 记录下正在通话的双方ID
      linkSocket.value.emit("callInfo", {
        roomId: props.roomId,
        calleeId,
        callerId,
      });
    }
    if (pc.connectionState === "disconnected") {
      console.log("连接断开");
      callSate[targetUserId] = "挂断";
    }
    if (pc.connectionState === "failed") {
      console.log("连接失败");
      callSate[targetUserId] = "通话失败";
      remoteVideo.value = false;
    }
    if (pc.connectionState === "connecting") {
      console.log("连接中");
      callSate[targetUserId] = "连接中";
    }
    if (pc.connectionState === "closed") {
      console.log("连接关闭");
    }
  };

  // 当 ICE 框架收集到新的 ICE 候选时触发,用于处理 ICE 候选的事件
  pc.onicecandidate = (e) => {
    if (e.candidate) {
      const params = {
        userId,
        targetUserId,
        candidate: e.candidate,
      };
      linkSocket.value.emit("candidate", params);
    } else {
      console.log("在此次协商中,没有收集到新的 ICE 候选");
    }
  };
};

onMounted(() => {
  const userId = nanoid();
  const { nickName, roomId } = props;
  if (nickName && roomId && userId) {
    init(userId, roomId, nickName);
  }
});

总的来说,就是要思路清晰,知道每个步骤要干什么,然后处理相应的事件就好了。一方做了什么,另一方需要相应的做出回复。就这么一来一回,看着是有点啰里啰嗦,感觉很复杂,其实非常简单。一定非常熟悉媒体协商的具体过程,这个是重点。

这里补充一点PeerConnection的事件,更详细的信息去这里

image.png

小节

本小节介绍了如何实现一个简单的音视频通话,分析了具体的实现逻辑,然后给出了具体的代码示例供参考。重点在于熟悉媒体协商的步骤,这个是必不可少的。其次就是要学会角色扮演,需要同时站在发起方和接受方的角度去思考,如何去进行连接,有哪些可能的步骤,可能会有哪些问题。

下一小节将会在此基础上拓展功能,比如发送消息、桌面共享或者发送文件等,冲!!