用 WebRTC、Socket.io、Node.js 和 React 构建实时视频聊天应用

本文档提供了使用 WebRTC 和 Socket.IO 构建实时视频聊天应用的完整实现。该应用支持用户之间的无缝视频和音频通信,具有静音音频、暂停视频和处理通话事件等功能。

关键特性:

视频和音频通信:使用 WebRTC 进行实时流媒体传输。 通话管理:处理来电和去电,包括提议/应答和 ICE 候选者交换。 用户控制:切换音频和视频的开/关,开始和结束通话。 通知:提供通话状态和通话管理操作的警报。

实现细节:

Socket.IO 集成:管理实时信令和事件处理。 RTCPeerConnection:建立对等连接并处理 ICE 候选者。 媒体流处理:管理本地和远程的视频和音频媒体流。 用户界面控制:提供直观的按钮来控制视频和音频。

组件:

应用组件:初始化和管理视频通话的核心组件。 事件处理程序:用于管理提议、应答、候选者和挂断的函数。 用户界面:具有通话操作控制的响应式布局。

此实现确保了实时通信的强大和交互式用户体验,并具有用于管理视频通话的清晰有效的用户界面元素。

WebRTC 的最小 API: WebRTC 由几个关键 API 组成,以实现其功能:

  • getUserMedia API:此 API 允许 Web 应用程序访问用户的摄像头和麦克风,实现实时音频和视频流。

  • RTCPeerConnection API:此 API 建立和管理对等连接,促进浏览器之间的音频和视频通信。它处理网络协议、编解码器和加密等安全功能。

  • RTCDataChannel API:除了音频和视频流,此 API 提供了一个对等数据通道,用于在浏览器之间直接交换任意数据(文件、文本等)。

欲知更多

io.on("connection", (socket) => {
  console.log("Connected");
​
  socket.on("calling", (message) => {
​
    socket.broadcast.emit("calling", message);
  });
​
  socket.on("disconnect", () => {
    console.log("Disconnected");
  });
});

为了在两个特定用户之间建立直接连接,您可以使用 Socket.io 从一个用户向预期的接收者发出呼叫事件。这确保连接请求专门发送到目标用户,而不是广播给所有连接的用户。

io.on("connection", (socket) => {
  console.log("Connected");
​
​
  socket.on("calling", (message) => {
​
    io.to(message.userId).emit("calling", message);
​
  });
​
  socket.on("disconnect", () => {
    console.log("Disconnected");
  });
});

对等连接

const configuration = {
  iceServers: [
    {
      urls: ["stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302"],
    },
  ],
  iceCandidatePoolSize: 10,
};
​
try {
    pc.current = new RTCPeerConnection(configuration);
    pc.current.onicecandidate = (e) => {
      const message = {
        type: "candidate",
        candidate: null,
        id:userInfo,
      };
      if (e.candidate) {
        message.candidate = e.candidate.candidate;
        message.sdpMid = e.candidate.sdpMid;
        message.sdpMLineIndex = e.candidate.sdpMLineIndex;
      }
      socket.emit("calling", message);
    };
    pc.current.ontrack = (e) => (remoteVideo.current.srcObject = e.streams[0]);
    localStream.current.getTracks().forEach((track) => pc.current.addTrack(track, localStream.current));
    const offer = await pc.current.createOffer();
    socket.emit("calling", {id:userInfo, type: "offer", sdp: offer.sdp });
    await pc.current.setLocalDescription(offer);
  } catch (e) {
    console.log(e);
  }

处理提议、应答和候选者:

  try {
      pc.current = new RTCPeerConnection(configuration);
      pc.current.onicecandidate = (e) => {
        const message = {
          type: "candidate",
          id: userInfo,
          candidate: e.candidate? e.candidate.candidate : null,
          sdpMid: e.candidate? e.candidate.sdpMid : undefined,
          sdpMLineIndex: e.candidate? e.candidate.sdpMLineIndex : undefined,
        };
        socket.emit("calling", message);
      };
      pc.current.ontrack = (e) => (remoteVideo.current.srcObject = e.streams[0]);
      localStream.current.getTracks().forEach((track) => pc.current.addTrack(track, localStream.current));
      await pc.current.setRemoteDescription(offer);
      const answer = await pc.current.createAnswer();
      socket.emit("calling", { id: userInfo, type: "answer", sdp: answer.sdp });
      await pc.current.setLocalDescription(answer);
    } catch (e) {
      console.log(e);
    }

处理应答:

async function handleAnswer(answer) {
  if (!pc.current) {
    console.error("no peerconnection");
    return;
  }
  try {
    await pc.current.setRemoteDescription(answer);
  } catch (e) {
    console.log(e);
  }
}

处理候选者:

 async function handleCandidate(candidate) {
    try {
      if (!pc.current) {
        console.error("no peerconnection");
        return;
      }
      await pc.current.addIceCandidate(candidate? candidate : null);
    } catch (e) {
      console.log(e);
    }
  }

挂断通话:

async function hangup() {
  if (pc.current) {
    pc.current.close();
    pc.current = null;
  }
  localStream.current.getTracks().forEach((track) => track.stop());
  localStream.current = null;
  startButton.current.disabled = false;
  hangupButton.current.disabled = true;
  muteAudButton.current.disabled = true;
  muteVideo.current.disabled = true;
​
}

React 组件实现:

import { useRef, useEffect, useState } from "react";
import { FiVideo, FiVideoOff, FiMic, FiMicOff } from "react-icons/fi";
import { FaMicrophone, FaMicrophoneSlash, FaVideo, FaVideoSlash, FaPhone, FaTimes } from 'react-icons/fa';
import { io } from "socket.io-client";
import Swal from 'sweetalert2';
​
const socket = io("http://localhost:3000", { transports: ["websocket"] });
​
const configuration = {
  iceServers: [
    { urls: ["stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302"] },
  ],
  iceCandidatePoolSize: 10,
};
​
function App() {
​
  const userInfo = 123456 // Assume userId is defined elsewhere
​
  const pc = useRef(null);
  const localStream = useRef(null);
  const startButton = useRef(null);
  const hangupButton = useRef(null);
  const muteAudButton = useRef(null);
  const localVideo = useRef(null);
  const remoteVideo = useRef(null);
  const muteVideoButton = useRef(null);
  const muteVideo = useRef(null);
​
  socket.on("calling", (e) => {
    if (!localStream.current) {
      console.log("not ready yet");
      return;
    }
    switch (e.type) {
      case "offer":
        handleOffer(e);
        break;
      case "answer":
        handleAnswer(e);
        break;
      case "candidate":
        handleCandidate(e);
        break;
      case "ready":
        if (pc.current) {
          alert("already in call ignoring");
          return;
        }
        makeCall();
        break;
      case "bye":
        if (pc.current) {
          hangup();
        }
        break;
      default:
        console.log("unhandled", e);
        break;
    }
  });
​
​
​
  async function makeCall() {
    try {
      pc.current = new RTCPeerConnection(configuration);
      pc.current.onicecandidate = (e) => {
        const message = {
          type: "candidate",
          candidate: e.candidate ? e.candidate.candidate : null,
          sdpMid: e.candidate ? e.candidate.sdpMid : undefined,
          sdpMLineIndex: e.candidate ? e.candidate.sdpMLineIndex : undefined,
          id: userInfo,
        };
        socket.emit("calling", message);
      };
      pc.current.ontrack = (e) => (remoteVideo.current.srcObject = e.streams[0]);
      localStream.current.getTracks().forEach((track) => pc.current.addTrack(track, localStream.current));
      const offer = await pc.current.createOffer();
      socket.emit("calling", { id: userInfo, type: "offer", sdp: offer.sdp });
      await pc.current.setLocalDescription(offer);
    } catch (e) {
      console.log(e);
    }
  }
​
  async function handleOffer(offer) {
    if (pc.current) {
      console.error("existing peerconnection");
      return;
    }
    try {
      pc.current = new RTCPeerConnection(configuration);
      pc.current.onicecandidate = (e) => {
        const message = {
          type: "candidate",
          id: userInfo,
          candidate: e.candidate ? e.candidate.candidate : null,
          sdpMid: e.candidate ? e.candidate.sdpMid : undefined,
          sdpMLineIndex: e.candidate ? e.candidate.sdpMLineIndex : undefined,
        };
        socket.emit("calling", message);
      };
      pc.current.ontrack = (e) => (remoteVideo.current.srcObject = e.streams[0]);
      localStream.current.getTracks().forEach((track) => pc.current.addTrack(track, localStream.current));
      await pc.current.setRemoteDescription(offer);
      const answer = await pc.current.createAnswer();
      socket.emit("calling", { id: userInfo, type: "answer", sdp: answer.sdp });
      await pc.current.setLocalDescription(answer);
    } catch (e) {
      console.log(e);
    }
  }
​
  async function handleAnswer(answer) {
    if (!pc.current) {
      console.error("no peerconnection");
      return;
    }
    try {
      await pc.current.setRemoteDescription(answer);
    } catch (e) {
      console.log(e);
    }
  }
​
  async function handleCandidate(candidate) {
    try {
      if (!pc.current) {
        console.error("no peerconnection");
        return;
      }
      await pc.current.addIceCandidate(candidate ? candidate : null);
    } catch (e) {
      console.log(e);
    }
  }
​
  async function hangup() {
    if (pc.current) {
      pc.current.close();
      pc.current = null;
    }
    localStream.current.getTracks().forEach((track) => track.stop());
    localStream.current = null;
    startButton.current.disabled = false;
    hangupButton.current.disabled = true;
    muteAudButton.current.disabled = true;
    muteVideo.current.disabled = true;
​
    closeVideoCall();
  }
​
  useEffect(() => {
    hangupButton.current.disabled = true;
    muteAudButton.current.disabled = true;
    muteVideo.current.disabled = true;
  }, []);
​
  const [audioState, setAudio] = useState(true);
  const [videoState, setVideoState] = useState(true);
​
  const startB = async () => {
    try {
      localStream.current = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: { echoCancellation: true },
      });
      localVideo.current.srcObject = localStream.current;
    } catch (err) {
      console.log(err);
    }
​
    startButton.current.disabled = true;
    hangupButton.current.disabled = false;
    muteAudButton.current.disabled = false;
    muteVideo.current.disabled = false;
​
    socket.emit("calling", { id: userInfo, type: "ready" });
  };
​
  const hangB = async () => {
    Swal.fire({
      title: 'Are you sure to cut the call?',
      showCancelButton: true,
      confirmButtonText: 'Yes',
      cancelButtonText: 'No',
    }).then((res) => {
      if (res.isConfirmed) {
        hangup();
        socket.emit("calling", { id: userInfo, type: "bye" });
      }
    });
  };
​
  function muteAudio() {
    if (localStream.current) {
      localStream.current.getAudioTracks().forEach(track => {
        track.enabled = !track.enabled; // Toggle mute/unmute
      });
      setAudio(!audioState); // Update state for UI toggle
    }
  }
​
  function pauseVideo() {
    if (localStream.current) {
      localStream.current.getVideoTracks().forEach(track => {
        track.enabled = !track.enabled; // Toggle video track
      });
      setVideoState(!videoState); // Update state for UI toggle
    }
  }
​
  return (
    <div className='bg-white w-screen h-screen fixed top-0 left-0 z-50 flex justify-center items-center'>
      <div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
        <div className='flex-1 p-4'>
          <div className='bg-gray-200 h-96 w-full md:w-96 rounded-lg shadow-md'>
            <video ref={localVideo} className='w-full h-full rounded-lg object-cover' autoPlay playsInline></video>
          </div>
        </div>
        <div className='flex-1 p-4'>
          <div className='bg-gray-200 h-96 w-full md:w-96 rounded-lg shadow-md'>
            <video ref={remoteVideo} className='w-full h-full rounded-lg object-cover' autoPlay playsInline></video>
          </div>
        </div>
      </div>
      <div className='absolute bottom-8 flex justify-center space-x-4'>
        <button className='p-2 rounded-full bg-gray-300 hover:bg-gray-400' ref={muteAudButton} onClick={muteAudio}>
          {audioState ? <FiMic /> : <FiMicOff />}
        </button>
        <button className='p-2 rounded-full bg-gray-300 hover:bg-gray-400' ref={startButton} onClick={startB}>
          <FaPhone className='text-gray-600' />
        </button>
        <button className='p-2 rounded-full bg-gray-300 hover:bg-gray-400' ref={muteVideo} onClick={pauseVideo}>
          {videoState ? <FaVideo className='text-gray-600' /> : <FaVideoSlash className='text-gray-600' />}
        </button>
        <button className='p-2 rounded-full bg-gray-300 hover:bg-gray-400' ref={hangupButton} onClick={hangB}>
          <FaTimes className='text-gray-600' />
        </button>
      </div>
    </div>
  );
}
​
export default App;

您可以根据您的受众和具体要求随意调整语气和内容。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

幻想多巴胺

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

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

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

打赏作者

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

抵扣说明:

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

余额充值