本文记录我的WebRTC一对一视频通话功能的学习过程,特别感谢B站UP主Red润的教学分享。

这篇文章内容有些方面解释的不到位或者错误的地方还请各位指正

先上一张webrtc视频通话流程图

然后我再说一说我自己理解的各个概念与流程思路的理解

一、caller和called双方连接信令服务器

通话最基础的原理是将自己的整个视频数据传输给对方从而实现视频通话功能,那么第一步需要将caller(发送方)called(接收方)同时将自身信息暴露给服务端,让服务端知道当前总共的用户,这一点有点像我之前学的nacos。

 

二、创建RTCPeerConnection并输出offer SDP(Session Description Protocol)

caller(发送方)需要先创建RTCPeerConnection,这是WebRTC的核心API,用于管理P2P连接,通过将发送方的视频流、音频流、屏幕视频流(用于屏幕共享)进行获取,然后输出一个offer SDP,这个offer SDP仅包含视频流和音频流的“元信息”或“协商参数”,而不是视频流和音频流的“实际内容”,描述本地的媒体参数(如编码格式、端口等)。

以下是offer SDP数据的举例

m=audio 49170 RTP/AVP 96 97 # 媒体类型为音频,端口 49170,编码格式为 payload type 96/97

a=rtpmap:96 opus/48000/2 # payload type 96 对应 Opus 编码,采样率 48kHz,双声道

a=rtpmap:97 G722/8000 # payload type 97 对应 G722 编码,采样率 8kHz

m=video 5004 RTP/AVP 96 97 98 # 媒体类型为视频,端口 5004,支持 VP8、H264 等编码

a=rtpmap:96 VP8/90000 # payload type 96 对应 VP8 编码

a=rtpmap:97 H264/90000 # payload type 97 对应 H264 编码

a=fmtp:97 profile-level-id=42e01f; ... # H264 的具体配置参数

 

三、caller(发送方)发送offer SDP与called(接收方)发送Answer SDP

caller(发送方)将收集好的offer SDP发送给服务端(这里通常情况下携带独立标识如发送方id与组织标识如房间号或频段),服务器接收后通过查询同一房间号或频段下的用户数据表(如记录用户id的数组),广播offer SDP(分别向接收方发送offer SDP),此时在同一房间下的called(接收方)就会拿到这个offer SDP,然后设置发送方的参数。

在接收方收到offer SDP后,接收方会创建自己的RTCPeerConnection对象 ,收集自己这边的answer SDP(叫法不同,其实都是视频流、音频流、屏幕视频流的相关信息),然后将answer SDP发送给server服务端,并由服务端交给caller(发送方)和其他所有接收方、但是目前这篇文章讲的是一对一,所以只发给发送方,发送方接收到后设置接收方参数。此时完成双方媒体参数的交换

 

四、caller与called双方从STUN/TURN服务器上获取自己的公网ip/端口

STUN服务器作用:帮助接收方与发送方获取本机ip与端口(因为设备可能处于局域网或NAT后)

TURN服务器作用:如果NAT穿透失败(直接P2P不可行),通过TURN服务器中继传输数据,起到保险的效果。

双发通过RTCPeerConnection再与STUN/TURN服务器进行交互,获得本机ip与端口

 

五、双方交换Candidate信息(如ip+端口)

Candidate是网络地址候选信息(如IP+端口),用于尝试建立P2P连接。

Candidate的整个传输流程和刚才讲的SDP一摸一样,首先,双方通过STUN/TURN服务器获取自己的Candidate,然后,由发送方将Candidate信息发送给服务端,服务端将Candidate广播给接收方,接收方也将自己的Candidate发送给发送方,双方再把Candidate加入到RTCPeerConnection中,这样可以找到最佳的P2P连接路径(如直接连接或通过TURN中继)

 

 

六、建立P2P连接并通信

当双方完成SDP交换和candidate交换后,RTCPeerConnection会自动尝试建立最优的P2P连接。

成功后

音视频数据直接通过P2P传输(如果NAT穿透成功)。

如果失败,数据通过TURN服务器中继传输。

通信开始

双方通过RTCPeerConnection接收对方的音视频流,并渲染到浏览器页面中。

 

 

接下来是功能的实现源码

整个功能都是由nodejs 与 html实现的

首先需要拥有nodejs环境

然后cmd呼出控制台,cd到项目的根目录下,或者直接用编译器自带的控制台

执行命令,创建空项目

npm install

然后将以下三个包再导入进去,分别是nodemon   socket.io   express

npm install nodemon socket.io express

其中Nodemon 是一个基于 Node.js 开发的实用工具,用于监视文件变化并自动重启应用程序。

Socket.IO 可以在不同的平台、浏览器和设备上支持及时、双向与基于事件的交流。它使用WebSocket建立连接,提供低负载通信通道,并支持可扩展的应用程序部署和重新连接。

express用于建立信令服务器

接下来开始代码功能实现

 

建立信令服务器

根目录创建index.js文件,以下是index.js内容

​

import express from 'express';

const app = express();
app.use(express.static('./public'));



import https from 'https';
import fs from 'fs';

const options = {
  key: fs.readFileSync('./ssl/server.key'),//密钥
  cert: fs.readFileSync('./ssl/server.crt')//证书
};
const httpsServer = https.createServer(options, app);//创建https服务


import {Server} from 'socket.io';
import {initSDPServer} from './SDP/sdp.js';


const io = new Server(httpsServer,{allowEIO3:true,cors:true});//创建socket.io服务
initSDPServer(io);



import {getIpAddress} from './common.js';

httpsServer.listen(3000, () => {//监听3000端
    let IP = getIpAddress();
    if(IP === false){
        console.log(`https服务启动失败,请检查网络`);
    }
    let IpAddr= "https://"+getIpAddress()+":3000";

    console.log(`https服务启动成功,访问地址为:${IpAddr}`);
});


​

然后讲解一下每段代码的大致意思

首先引入express 并将根目录下public文件夹的所有代码静态资源托管

如果 public 目录下有一个文件 index.html,可以通过 http://localhost:3000/index.html 访问。

如果 public 目录下有子目录 css/style.css,可以通过 http://localhost:3000/css/style.css 访问。

​
import express from 'express'
const app = express();
app.use(express.static('./public'));

​

接下来导入https和fs包,https包支持访问https,fs负责读取文件数据

这里需要有密钥文件(server.key)和证书文件(server.crt),具体怎么获得可以去自己了解一下,这两个文件我就不提供给大家了,想要让项目支持https,这两个文件在项目中是必不可少的。

​
import https from 'https';
import fs from 'fs';//引入fs,作用是操作文件

const options = {
  key: fs.readFileSync('./ssl/server.key'),//密钥
  cert: fs.readFileSync('./ssl/server.crt')//证书
};
const httpsServer = https.createServer(options, app);//创建https服务

​

然后导入socket.io包

并且导入服务端文件 sdp.js (这个先不用管,一会也会展示出来)

创建socket.io服务 

执行服务端initSDPServer方法(这个也会讲到)

import {Server} from 'socket.io';
import {initSDPServer} from './SDP/sdp.js';



//io 是在项目启动时,通过 new Server(httpsServer, { ... }) 创建的 Socket.IO 服务实例。
//它是基于 HTTPS 服务器(httpsServer)建立的 WebSocket 服务,用于处理客户端与服务端之间的实时通信

const io = new Server(httpsServer,{allowEIO3:true,cors:true});//创建socket.io服务
initSDPServer(io);

导入common.js,这个文件中只有一个方法,就是获取ip地址

然后写了个服务监听,监听3000端口,若开启则向控制台输出服务ip地址

 

import {getIpAddress} from './common.js';

httpsServer.listen(3000, () => {//监听3000端
    let IP = getIpAddress();
    if(IP === false){
        console.log(`https服务启动失败,请检查网络`);
    }
    let IpAddr= "https://"+getIpAddress()+":3000";

    console.log(`https服务启动成功,访问地址为:${IpAddr}`);
});

 

根目录下的common.js

这里就是自动获取ip地址的逻辑

//获取本机ip地址
import {networkInterfaces} from 'os';

export  const getIpAddress = () =>{
    const interfaces = networkInterfaces();//获取所有网卡
    let ip = "";
    for(let devName in interfaces){
        let iface = interfaces[devName];//获取当前网卡
        let config = iface.find(item => item.family === 'IPv4' && item.internal === false);//获取非loopback的ipv4地址
        //如果有数据
        if(config && config.address){
            ip = config.address;
            return ip;
        }else{
            return false;
        }
    }
}

 

根目录下创建sdp.js

/*

         后端部分

*/

import {Server,Socket} from 'socket.io'

//房间容器
//map格式 Map<String,Integer>


const roomMap = new Map();

/**
 * 初始化SDP服务
 * @param {Server} io
 */
export const initSDPServer = (io) =>{//初始化SDP服务
    //只要客户端连接就调用这个方法
    io.on('connection', (socket) => {//监听连接io.on   connection标准事件名
        onEvent(socket)
    })
    
}

//具体客户端连接方法
/**
 * 监听事件
 * @param {Socket} socket
 * 
 * 
 * socket:是客户端连接到服务端后,服务端为其创建的一个 Socket 实例。
   socket.request:是一个 HTTP 请求对象(底层来自客户端首次握手的 HTTP 请求)。
   _query:是这个请求的查询参数(也就是 URL 中 ?key=value&... 的部分),已经被解析成一个对象。
 */

const onEvent = (socket) =>{
    let {roomId,userId} = socket.request._query;//获取url中的房间号和用户id

    //判断房间与房间人数

    if(!roomMap.has(roomId)){
        roomMap.set(roomId,1);
    }else{
        roomMap.set(roomId,roomMap.get(roomId)+1);
    }
    
    
    //监听用户断开连接
    socket.on("disconnect",()=>{
        console.log(`用户:${userId}已断开连接`);
        //socket通知房间内其他用户
        
        //暂时超过三个人不通知房间内其他用户
        if(roomMap.get(roomId)<=2){
            socket.to(roomId).emit("client-leave",userId+"已离开",{userId})
        }
        
        //房间人数减一
        roomMap.set(roomId,roomMap.get(roomId)-1);
        console.log(`当前房间人数:${roomMap.get(roomId)}`)
    })

    //监听房间已满
    if(roomMap.get(roomId)>2){
        socket.emit("room-full",roomId)
        return
    }

    console.log(`用户:${userId}加入房间:${roomId}`);
    console.log(`当前房间人数:${roomMap.get(roomId)}`)

    // 广播给房间内的其他人
    const joinMsg = `${userId} 已加入房间,当前房间人数:${roomMap.get(roomId)}`;
    socket.to(roomId).emit("join-room-msg", joinMsg);

    //当前用户socket对象加入房间
    socket.join(roomId)
    //告诉当前用户房间人数
    socket.emit("people-count-msg",roomMap.get(roomId))
    //告诉所有用户当前
    //socket.to(roomId).emit("people-count-msg",roomMap.get(roomId))

   
    
    //发送方端通过服务端发送SDP(后来的用户给先来的用户发送)
    socket.on("offer-sdp-msg",(offerSDP)=>{
        //向其他所有在一个房间的用户发送发送方的SDP   (接收方获取发送方配置信息)
        socket.to(roomId).emit("offer-sdp-msg",offerSDP)
    })

    //接收方端通过服务端发送SDP(先来的用户接收到SDP后再将自己的SDP发送给后来的用户)
    socket.on("answer-sdp-msg",(answerSDP)=>{
        //向其他所有在一个房间的用户发送接收方的SDP   (发送方获取接收方配置信息)
        socket.to(roomId).emit("answer-sdp-msg",answerSDP)
    })


    //监听对方用户端的candidate信息
    socket.on("candidate-msg",(candidateInfo)=>{ 
        //向其他用户发送candidate信息
        socket.to(roomId).emit("candidate-msg",candidateInfo)
    })

    //接收用户端发送加入房间的消息
    socket.on("join-room",(roomId,userId)=>{
        let msg = userId+"已加入房间"
        socket.to(roomId).emit("join-room-msg",msg)
    })

    

}

房间管理逻辑

const roomMap = new Map(); // 存储房间ID和当前人数

 

使用Map结构存储每个房间的在线人数。键为房间ID(roomId),值为当前人数。

当新用户连接时:

let {roomId, userId} = socket.request._query; // 从URL参数获取房间和用户ID
if (!roomMap.has(roomId)) {
    roomMap.set(roomId, 1); // 新房间初始化为1人
} else {
    roomMap.set(roomId, roomMap.get(roomId) + 1); // 已有房间人数+1
}

 

人数限制与通知

如果房间人数超过2人(即3人及以上),新用户会收到"room-full"事件:

if (roomMap.get(roomId) > 2) {
    socket.emit("room-full", roomId); // 通知当前用户房间已满
    return; // 终止后续逻辑
}

 

当用户加入时,广播通知其他用户:

socket.to(roomId).emit("join-room-msg", `${userId}已加入`);
socket.emit("people-count-msg", roomMap.get(roomId)); // 仅通知当前用户人数

 

用户离开处理

监听disconnect事件,减少房间人数并通知剩余用户(仅限2人及以下房间):

socket.on("disconnect", () => {
    roomMap.set(roomId, roomMap.get(roomId) - 1);
    if (roomMap.get(roomId) <= 2) {
        socket.to(roomId).emit("client-leave", `${userId}已离开`);
    }
});

 

信令转发机制

实现WebRTC所需的SDP(Session Description Protocol)和ICE候选交换:

// 发送方发送offer SDP
socket.on("offer-sdp-msg", (offerSDP) => {
    socket.to(roomId).emit("offer-sdp-msg", offerSDP); // 转发给房间其他人
});

// 接收方回复answer SDP
socket.on("answer-sdp-msg", (answerSDP) => {
    socket.to(roomId).emit("answer-sdp-msg", answerSDP);
});

// ICE候选信息转发
socket.on("candidate-msg", (candidateInfo) => {
    socket.to(roomId).emit("candidate-msg", candidateInfo);
});

 

关键Socket.io方法

  • socket.join(roomId):将用户加入指定房间
  • socket.to(roomId).emit():向房间内其他用户广播消息
  • socket.emit():仅向当前连接用户发送消息

 

前端代码实现

在根目录下的public目录下创建index.html

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>webrtc</title>
</head>
<body>
    <input id="roomId" type="text" value="" placeholder="输入房间号">
    <input id="userId" type="text" value="" placeholder="输入用户id">
    <button class="start">开始通话</button>
    <button class="stop">暂 停/恢 复</button>
    <button class="video">视频通话</button>
    <button class="screen">屏幕共享</button>

    <!-- 视频容器 -->
    <div class="video-box">
        <video id="localVideo" autoplay controls muted></video>
    </div>

    <!-- 引入js -->
     <script type="module" src="index.js"></script>
</body>
</html>

两个输入框 分别收集房间号和用户id

四个按钮,负责进行socket连接,中断,以及切换视频流

然后是创建视频容器

最后引入script相关逻辑

 

在index.html同级目录创建index.js



import {io} from './utils/socket.io.esm.min.js'
import {getLocalMediaStream,setLocalVideoStream,createPeerConnection,createVideoElement,setRemoteVideoStream,getLocalScreenMediaStream} from './common.js'
//获取页面中用户传来的房间id和用户id
const roomInput = document.querySelector('#roomId')
const userInput = document.querySelector('#userId')

//监听事件,这是开始的第一步,接下来才是监听响应事件
const start = document.querySelector('.start')
const stop = document.querySelector('.stop')
const video = document.querySelector('.video')
const screen = document.querySelector('.screen')

//实时收集自己的视频流
let localVideo = document.querySelector('#localVideo')
let localStream = await getLocalMediaStream(
    {video: true, audio: true}//打开声音和视频
)
//刷新页面时,重新获取本地视频流
setLocalVideoStream(localVideo,localStream)


let isRoomFull = false//用于判断房间是否满人

let roomId;//房间id
let userId;//用户id

let client;//socket对象


/**
 * @type {RTCPeerConnection}
 */
let peer;

//获取当前服务端ip
 let serverUrl = 'wss://192.168.100.100:3000/'//服务端的地址


//是否第一次进入
 let isFirst = false;



//开始按钮监听
start.addEventListener('click', () => { 
    //点击后执行以下代码
    
    
    if(isRoomFull){
        alert('房间已满')
        return
    }

    //这个目前是一对一,第三个人不能加入
    
    
    //接收用户传来的房间号和用户id
    roomId = roomInput.value
    userId = userInput.value
    

    //判断当前用户socket会话对象是否拥有
    if(!client){
        //创建一个socket会话连接
        client = new io(serverUrl,{
            //配置信息
            reconnectDelayMat: 10000,//重连时间间隔
            transports: ['websocket'],//传输协议
            query:{
                roomId,//这里在服务端(sdp.js中onEvent方法中接收到这两个值)
                userId
            }
        })

       
        client.on('people-count-msg',async (UserCount)=>{
            console.log(`当前房间人数:${UserCount}`);

            //判断是否是第一个人加入房间
            if(UserCount==1){
                isFirst = true;
            }

            
            peer = createPeerConnection();// 创建一个RTCPeerConnection对象
            localStream.getTracks().forEach((track)=>{
                peer.addTrack(track,localStream)//将本地音视频轨道添加到peer中
            })
           
            /**
             *  @param {RTCPeerConnectionIceEvent} event
             */
            peer.onicecandidate = event => {// 发送ice信息给对方
                //媒体协商完成后才会触发
                if(event.candidate){
                    //console.log('onicecandidate:',event.candidate);
                    //双方都发送candidate信息
                    client.emit('candidate-msg',event.candidate)
                }
            }
             
            /**
             *  @param {RTCTrackEvent} event
             */
            peer.ontrack = event=>{//接收对方发送的音视频流
                //p2p连接成功后才会触发这个函数
                //获取对方的音视频流,然后设置给本地video元素
                console.log('p2p连接成功')
                let videoEle = createVideoElement(UserCount)
                setRemoteVideoStream(videoEle,event.track)
            }

            //这个注释之上是关于p2p连接的代码

            //接下来是关于offer SDP的代码
            
            if(!isFirst){
                //不是第一次进来,发送offer SDP
                let offerSDP = await peer.createOffer();
                await peer.setLocalDescription(offerSDP);
                client.emit("offer-sdp-msg",offerSDP);
            }
            isFirst = true;


        })

        client.on('offer-sdp-msg',async (offerSDP)=>{
            //所有之前进来的用户接收并保存后进来用户发送的 offerSDP
            peer.setRemoteDescription(offerSDP);
            //获取自己的answerSDP
            let answerSDP = await peer.createAnswer();
            //再将自己的 answerSDP保存起来
            await peer.setLocalDescription(answerSDP);

            // 发送answerSDP给后来的用户(先来的已经收到了后来的SDP,将自己的SDP发给后来的用户)
            client.emit("answer-sdp-msg",answerSDP);

        })
        //发送方接收 answerSDP
        client.on("answer-sdp-msg",async (answerSDP)=>{
            //接收到answerSDP,设置answerSDP
            await peer.setRemoteDescription(answerSDP);
        })

        //双方接收candidate
        client.on("candidate-msg",async (candidate)=>{
            //接收到candidate,设置candidate
            await peer.addIceCandidate(candidate);
        })


        //监听连接事件,监听sdp.js的connect事件
        client.on('connect',()=>{
           
            console.log(`用户:${userId}已连接`);
            
        })
        //实时监听当前房间人数
        client.on('roomUserCount',(UserCount)=>{
            console.log(`当前房间人数:${UserCount}`);
        })

        //监听断开连接事件
        client.on('disconnect',()=>{ 
            console.log(`用户:${userId}已断开连接`);
        })
        //监听错误事件
        client.on('error',(error)=>{ 
            console.log(`用户:${userId}错误`);
        })


        // 监听来自服务端的“用户加入”广播即可
        client.on("join-room-msg", (msg) => {
            console.log(msg);
        });

        //广播退出事件
        client.on("client-leave",(anotherUserId)=>{
            console.log(`用户:${anotherUserId}`);
        })

        client.on("room-full",()=>{
            alert('房间已满')
            isRoomFull = true
        })

        

    }


})
//是否停止音频和视频
let isStopAudio = false
let isStopVideo = false


//暂停事件
stop.addEventListener ("click", () => { 
    if(peer){
        isStopAudio = !isStopAudio;
        isStopVideo = !isStopVideo;
        peer.getSenders().find(sender =>sender.track.kind === "audio").track.enabled = !isStopAudio;
        peer.getSenders().find(sender =>sender.track.kind === "video").track.enabled = !isStopVideo;
    }
});


//切回视频流
video.addEventListener("click",async () => { 
    let newStream = await getLocalMediaStream({video: true, audio: true})
    if(newStream){
        localStream = newStream
        setLocalVideoStream(localVideo,newStream)

        //再更改远程视频流
        localStream.getVideoTracks().forEach(track =>{
            peer.getSenders().find(sender => sender.track.kind === track.kind).replaceTrack(track);
        })
    }

});




//屏幕共享事件
screen.addEventListener ("click",async () => {
    // 获取屏幕共享媒体流
    let newStream = await getLocalScreenMediaStream({
        video:{
            cursor:"always" | "motion"| "never" ,
            displaySurface:'application' | 'browser' | 'monitor' | 'window'
        }
    });
    // 设置本地视频流
    if(newStream){
        // 设置本地视频流
        localStream = newStream;
        setLocalVideoStream(localVideo,localStream);

        //再更改远程视频流
        localStream.getVideoTracks().forEach(track =>{
            peer.getSenders().find(sender => sender.track.kind === track.kind).replaceTrack(track);
        })
    }
});

1. 初始化与环境准备

首先,我们需要引入一些必要的库文件,比如socket.io用于实时通信,以及一系列自定义函数(getLocalMediaStream, setLocalVideoStream等),这些函数帮助我们处理音视频流的获取与展示

import {io} from './utils/socket.io.esm.min.js'
import {getLocalMediaStream,setLocalVideoStream,createPeerConnection,createVideoElement,setRemoteVideoStream,getLocalScreenMediaStream} from './common.js'

同时,我们还需要从页面中获取房间ID和用户ID输入框的值,以便后续加入特定的聊天室。

2. 视频流的获取与显示

接下来,我们会尝试获取用户的本地音视频流,并将其设置为页面上视频元素的源。

let localStream = await getLocalMediaStream(
    {video: true, audio: true}//打开声音和视频
)
setLocalVideoStream(localVideo,localStream)

这里,getLocalMediaStream函数会请求用户的摄像头和麦克风权限,一旦获得授权,就能通过setLocalVideoStream将捕获到的媒体流显示在页面上。

3. 创建Socket连接并处理房间逻辑

为了实现实时通讯,我们使用Socket.IO建立了一个WebSocket连接。当用户点击“开始”按钮时,如果房间未满,就会创建这个连接,并发送包含房间ID和用户ID的信息。

client = new io(serverUrl,{
    query:{
        roomId,
        userId
    }
})

4. 实现P2P连接

一旦建立了Socket连接,我们就进入了点对点(P2P)连接的关键步骤。这包括创建RTCPeerConnection对象、添加本地音视频轨道、处理ICE候选者和SDP交换等。

5. 控制与切换

最后,我们提供了一些控制功能,如暂停/恢复音频和视频流、切换回普通视频流以及屏幕共享等。这些操作主要是通过更新peer.getSenders()中的track来实现的。

 

 

 

 

 

在同级目录再创建common.js

/**
 * @param {MediaStreamConstraints} constraints
 */



export const getLocalMediaStream = async (constraints)=>{
    try{
       let stream = await navigator.mediaDevices.getUserMedia(constraints)
       return stream
    }catch(error){
        console.log("获取视频流失败")
    }
    
}
export const getLocalScreenMediaStream = async (constraints) =>{
    try{
        let stream = await navigator.mediaDevices.getDisplayMedia(constraints)
        return stream
    }catch(error){
         console.log("获取屏幕共享失败")
    }
}




/**
 * @param {HTMLVideoElement} videoElement
 * @param {MediaStream} newStream
 * 
 * 
 */
export const setLocalVideoStream = async (videoElement, newStream) => {
    if (videoElement) {
        let oldStream = videoElement.srcObject;

        if (oldStream && typeof oldStream.getTracks === 'function') {
            // 确保 getTracks 是函数
            oldStream.getTracks().forEach(track => {
                oldStream.removeTrack(track);
            });
        }

        videoElement.srcObject = newStream;
    }
};

/**
 * 
 * @param {HTMLVideoElement} videoElement 
 * @param {MediaStreamTrack} track 
 * 
 * 将远端传来的单个 MediaStreamTrack 加入一个新的 MediaStream,然后赋值给 video 元素的 srcObject,实现播放。
 */
export const setRemoteVideoStream = ( videoElement,track)=>{//对方的 音视频流和track音视频 轨道
    if(videoElement){
        let stream = videoElement.srcObject;
        if(stream){
            stream.addTrack(track)
        }else{
            let newStream = new MediaStream()
            newStream.addTrack(track)
            videoElement.srcObject = newStream
        }
        
    }

}



export const createPeerConnection = ()=>{
    const peer = new RTCPeerConnection({
        // bundlePolicy: "max-bundle",// 绑定策略
        // rtcpMuxPolicy: "require",// rtcp 策略
        // iceTransportPolicy : "relay",
        // iceServers: [
        //     {urls: "stun:stun.l.google.com:19302"}
        // ]
    })

    return peer
}

export const createVideoElement = (count) =>{
    let video_container = document.querySelector (".video-box")// 获取音视频父容器
    
    /**
     *  @type {HTMLVideoElement}
     */
    let video = document.querySelector("#video"+count)
    if(!video){
        video = document.createElement("video")
        video.muted = true
        video.autoplay = true
        video.controls  = true
        video.id = "video"+count
        video_container.appendChild(video)
    }

    return video
}

1. 获取本地媒体流:getLocalMediaStream 和 getLocalScreenMediaStream

export const getLocalMediaStream = async (constraints) => {
    try {
        let stream = await navigator.mediaDevices.getUserMedia(constraints);
        return stream;
    } catch (error) {
        console.log("获取视频流失败");
    }
};
  • 功能:通过 navigator.mediaDevices.getUserMedia 请求用户的摄像头和麦克风权限,返回本地音视频流(MediaStream)。
  • 参数
    • constraints:媒体约束对象,例如 { video: true, audio: true }
  • 用途:初始化本地视频流(如用户进入聊天室时)或切换回普通视频流。
  • 错误处理:如果用户拒绝权限或设备不可用,会捕获错误并提示“获取视频流失败”。
export const getLocalScreenMediaStream = async (constraints) => {
    try {
        let stream = await navigator.mediaDevices.getDisplayMedia(constraints);
        return stream;
    } catch (error) {
        console.log("获取屏幕共享失败");
    }
};
  • 功能:请求用户共享屏幕(或窗口),返回屏幕共享的 MediaStream
  • 参数
    • constraints:屏幕共享的约束,例如指定共享整个屏幕或特定窗口。
  • 用途:实现屏幕共享功能(例如用户点击“屏幕共享”按钮时)。
  • 错误处理:如果用户取消共享或系统不支持,会提示“获取屏幕共享失败”。

2. 设置本地视频流:setLocalVideoStream

export const setLocalVideoStream = async (videoElement, newStream) => {
    if (videoElement) {
        let oldStream = videoElement.srcObject;

        if (oldStream && typeof oldStream.getTracks === 'function') {
            oldStream.getTracks().forEach(track => track.stop());
        }

        videoElement.srcObject = newStream;
    }
};
  • 功能:将新的 MediaStream 绑定到页面上的 <video> 元素,并释放旧的媒体流。
  • 关键步骤
    1. 停止旧的媒体流的所有轨道(track.stop()),避免资源泄漏。
    2. 将新的 MediaStream 赋值给 videoElement.srcObject
  • 用途:初始化本地视频流、切换回普通视频流或切换到屏幕共享流。

3. 设置远程视频流:setRemoteVideoStream

export const setRemoteVideoStream = (videoElement, track) => {
    if (videoElement) {
        let stream = videoElement.srcObject;
        if (stream) {
            stream.addTrack(track);
        } else {
            let newStream = new MediaStream();
            newStream.addTrack(track);
            videoElement.srcObject = newStream;
        }
    }
};
  • 功能:将远程用户的单个 MediaStreamTrack(音视频轨道)添加到页面上的 <video> 元素中。
  • 逻辑
    • 如果 videoElement 已有 srcObject(即已有流),直接添加新轨道。
    • 如果没有,则创建新的 MediaStream,添加轨道后绑定到 videoElement
  • 用途:当 P2P 连接成功后,接收并显示远程用户的音视频流。

4. 创建 P2P 连接:createPeerConnection

export const createPeerConnection = () => {
    const peer = new RTCPeerConnection({
        // iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
    });
    return peer;
};
  • 功能:创建一个 RTCPeerConnection 对象,用于管理 P2P 连接。
  • 配置项
    • 注释掉的 iceServers 是 STUN/TURN 服务器的配置(可选)。如果需要穿透 NAT 或防火墙,需启用并配置这些服务器。
  • 用途:在客户端代码中被调用,作为 P2P 通信的核心对象,用于添加媒体轨道、交换 SDP 和 ICE 候选者。

5. 动态创建视频元素:createVideoElement

export const createVideoElement = (count) => {
    let video_container = document.querySelector(".video-box");

    let video = document.querySelector("#video" + count);
    if (!video) {
        video = document.createElement("video");
        video.muted = true; // 静音
        video.autoplay = true;
        video.controls = true;
        video.id = "video" + count;
        video_container.appendChild(video);
    }

    return video;
};
  • 功能:根据房间人数动态创建 <video> 元素,并添加到页面容器中。
  • 关键逻辑
    • 使用 count 参数生成唯一的 ID(例如 #video1#video2),避免重复。
    • 设置 muted(静音)和 autoplay(自动播放),确保远程视频流正常播放。
  • 用途:当用户加入房间时,动态生成视频元素以显示自己和对方的视频流。

 

为了使客户端也能使用socket.io,需要将node_modules里的socket.io下的client-dist里的socket.io.esm.min.js文件复制到和pulic目录下的utlis目录下

都完成后在根目录控制台上输入

nodemon index.js

即可运行这个项目了

这些就是整个一对一单聊与屏幕共享的所有代码了。用户端代码实在是太多了,并且逻辑复杂,所以我都用注释标注上了,感谢各位浏览本文章。

 

 

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐