本文档提供了使用 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;
您可以根据您的受众和具体要求随意调整语气和内容。