WebRTC 实现直播会议

126 阅读4分钟

核心思路:

onPcEvent(pc,localUid,remoteUid){
        const that = this
        this.channel = pc.createDataChannel("chat");
        pc.ontrack = function(event) {
                that.setRemoteDomVideoStream("remoteVideo01",event.track)
        };
        pc.onnegotiationneeded = function(e){
                console.log("重新协商",e)
        }
        pc.ondatachannel = function(ev) {
          console.log('Data channel is created!');
        };
        pc.onicecandidate = (event) => {
          if (event.candidate) {
                that.linkSocket.emit('candidate',{'targetUid':remoteUid,"userId":localUid,"candidate":event.candidate})
          } else {
            /* 在此次协商中,没有更多的候选了 */
                console.log("在此次协商中,没有更多的候选了")
          }
        }
}
  • 创建offer信令设置为本地描述后发送给 B 。
  • 等 B 创建应答信令之后,信令服务器会将其转发到 A 这边。
async onRemoteAnswer(fromUid,answer){
    await this.localRtcPc.setRemoteDescription(answer);
}
  • A 接受 B 的 answer信令后,将其设置为 remoteDesc 

注意看日志中的candidate,这个过程是贯穿整个会话的,直到ice候选完成。

被呼叫端B

被呼叫端的过程和呼叫端类似,大体代码如下:

async initCalleeInfo(localUid,fromUid){
        //初始化pc
        this.localRtcPc = new PeerConnection()
        //初始化本地媒体信息
        let localStream = await this.getLocalUserMedia({ audio: true, video: true })
        for (const track of localStream.getTracks()) {
            this.localRtcPc.addTrack(track);
          }
          //dom渲染
        await this.setDomVideoStream("localdemo01",localStream)
        //监听
        this.onPcEvent(this.localRtcPc,localUid,fromUid)

    }
  • B 接听后同时初始化 pc。
  • B 创建本地mediaStream,并添加到 pc 对象中,同时渲染在本地预览 Dom 元素。
  • 同 A 初始化回调监听。
  • 当然此时 A 发送的offer信令通过信令服务器转发到 B 这边,B 将其设置为remoteDesc后,同时创建answer信令。
async onRemoteOffer(fromUid,offer){
    //B接受到A的offer 设置为remote desc
    this.localRtcPc.setRemoteDescription(offer)
    //创建应答
    let answer = await this.localRtcPc.createAnswer();
    //设置为local desc
    await this.localRtcPc.setLocalDescription(answer);
    //并通过信令服务器发送给A
    let params = {"targetUid":fromUid,"userId":getParams("userId"),"answer":answer}
    this.linkSocket.emit("answer",params)
    }

在双方监听的 pc 核心方法ontrack中,就能拿到双方的音频和视频信息了 ( B 能显示 A 音频 就是靠从ontrack拿到 A 的音频数据 然后显示到 B 的 Vidio 标签中)

createDataChannel 这个方法是 WebRTC对象是自带的一个 可以不通过中专服务器就可以直接向链接端发送 文本信息的方法(类 IM 数据发送)

本质上 就是 A 创建好 Web RTC 对象已经初始化好各种链接,然后当发起通话链接动作的时候,把动作发给 server 端(通过 web socket),B 接收到拿到视频流信息 然后完成视频通话

核心代码部分

主要步骤

<template>
	<div style="width: 98%;height: 98vh;margin-top: 30px;">
		<el-row :gutter="20">
			<el-col :span="6">
				<div style="width: 100%;height: 800px;"  >
					<ul v-for="(item,index) in roomUserList" :key="index">
						<el-tag size="mini" @click="getStats"  type="success">{{'用户'+item.nickname}}</el-tag>
						<el-tag v-if="userInfo.userId === item.userId" type="danger" size="mini" @click="changeBitRate()"  >增加比特率</el-tag>
						<el-button size="mini" type="primary" v-if="userInfo.userId !== item.userId" @click="call(item)">通话</el-button>
						<el-button v-if="userInfo.userId === item.userId" size="mini" type="danger"@click="openVideoOrNot">切换</el-button>
					</ul>
				</div>
			</el-col>
			<el-col :span="18">
	
				<el-row>
					<div style="width: 800px;height: 200px;display: flex;flex-direction: row;align-items: center;">
						<el-form  :model="formInline" label-width="250px" class="demo-form-inline">
						  <el-form-item label="发送消息">
						    <el-input v-model="formInline.rtcmessage"  placeholder="消息"></el-input>
						  </el-form-item>
						  <el-form-item label="远端消息">
						    <div>{{formInline.rtcmessageRes}}</div>
						  </el-form-item>
						  						
						  <el-form-item>
						    <el-button type="primary" @click="sendMessageUserRtcChannel">点击发送</el-button>
						  </el-form-item>
						</el-form>

					</div>
				</el-row>
				<el-row>
					<div style="display: flex;flex-direction: row;justify-content: flex-start;">
						<video @click="streamInfo('localdemo01')" id="localdemo01" autoplay controls muted></video>
						<video @click="streamInfo('remoteVideo01')" id="remoteVideo01" autoplay controls muted></video>
					</div>
				</el-row>
			</el-col>
		</el-row>
	</div>
</template>

<script>
	
	function handleError(error) {
	    // alert("摄像头无法正常使用,请检查是否占用或缺失")
	    console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
	}
	
	
	var PeerConnection = window.RTCPeerConnection ||
	        window.mozRTCPeerConnection ||
	        window.webkitRTCPeerConnection;
	const { io } = require("socket.io-client");
	function getParams(queryName){
		let url = window.location.href
		let query = decodeURI(url.split('?')[1]);
		let vars = query.split("&");
		for (var i = 0; i < vars.length; i++) {
		  var pair = vars[i].split("=");
		  if (pair[0] === queryName) {
		    return pair[1];
		  }
		}
		return null;
	}
	
	export default {
		name:'demo03-one2one',
		data(){
			return{
				linkSocket:undefined,
				rtcPcParams:{
				 iceServers: [
					{ url: "stun:stun.l.google.com:19302"},// 谷歌的公共服务
					]
				},
				roomUserList:[],
				userInfo:{},//用户信息
				formInline:{
					rtcmessage:undefined,
					rtcmessageRes:undefined,//响应
					
				
				},
				localRtcPc:undefined,
				rtcmessage:undefined,
				mapSender:[],//发送的媒体
				
			}
		},
		created() {
			if(getParams("userId")){
				this.init(getParams("userId"),getParams("roomId"),getParams('userId'))
			}
		},
		methods:{
			async setDomVideoStream(domId,newStream){
				let video = document.getElementById(domId)
				let stream = video.srcObject
				if(stream){
				    stream.getAudioTracks().forEach( e=>{
				        stream.removeTrack(e)
				    })
				    stream.getVideoTracks().forEach(e=>{
				        stream.removeTrack(e)
				    })
				}
				video.srcObject =newStream
				video.muted = true
			},
			setRemoteDomVideoStream(domId,track){
				let video = document.getElementById(domId)
				let stream = video.srcObject
				if(stream){
					stream.addTrack(track)
				}else{
					let newStream = new MediaStream()
					newStream.addTrack(track)
					video.srcObject =newStream
					video.muted = true
				}
			},
			init(userId,roomId,nickname){
				const that = this
				this.userInfo = {
					userId:userId,
					roomId:roomId,
					nickname:nickname
				}
				this.linkSocket = io(this.$serverSocketUrl, {
					reconnectionDelayMax: 10000,
					transports: ["websocket"],
					query: {
					  "userId": userId,
					  "roomId": roomId,
					  "nickname":nickname
					}
				});
				this.linkSocket.on("connect",(e)=>{
					console.log("server init connect success",that.linkSocket)
				})
				this.linkSocket.on("roomUserList",(e)=>{
					console.log("roomUserList",e)
					that.roomUserList = e					
				})
				this.linkSocket.on("msg",async (e)=>{
					console.log("msg",e)
					if(e['type'] === 'join' || e['type'] === 'leave'){
						setTimeout(()=>{
							let params = {"roomId":getParams('roomId')}
							that.linkSocket.emit('roomUserList',params)
						},1000)
					}
					if(e['type'] === 'call'){
						await that.onCall(e)
					}
					if(e['type'] === 'offer'){
						await that.onRemoteOffer(e['data']['userId'],e['data']['offer'])
					}
					if(e['type'] === 'answer'){
						await that.onRemoteAnswer(e['data']['userId'],e['data']['answer'])
					}
					if(e['type'] === 'candidate'){
						that.localRtcPc.addIceCandidate(e.data.candidate)
					}
				})
				this.linkSocket.on("error",(e)=>{
					console.log("error",e)
				})
			},
			/**
			 * 获取设备 stream
			 * @param constraints
			 * @returns {Promise<MediaStream>}
			 */
			async getLocalUserMedia(constraints){
			    return await navigator.mediaDevices.getUserMedia(constraints).catch(handleError)
			},
			async call(item){
				this.initCallerInfo(getParams('userId'),item.userId)
				let params ={
					"userId": getParams('userId'),"targetUid":item.userId}
				this.linkSocket.emit('call',params)
			},
			async onCall(e){
				console.log("远程呼叫:",e)
				await this.initCalleeInfo(e.data['targetUid'],e.data['userId'])
			},
			async initCalleeInfo(localUid,fromUid){
				//初始化pc
				this.localRtcPc = new PeerConnection()
				//初始化本地媒体信息
				let localStream = await this.getLocalUserMedia({ audio: true, video: true })
				for (const track of localStream.getTracks()) {
				    this.localRtcPc.addTrack(track);
				  }
				  // dom渲染
				await this.setDomVideoStream("localdemo01",localStream)
				//监听
				this.onPcEvent(this.localRtcPc,localUid,fromUid)
				
			},
			async initCallerInfo(callerId,calleeId){
				this.mapSender = []
				//初始化pc
				this.localRtcPc = new PeerConnection()
				//获取本地媒体并添加到pc中
				let localStream = await this.getLocalUserMedia({ audio: true, video: true })
				for (const track of localStream.getTracks()) {
				    this.mapSender.push(this.localRtcPc.addTrack(track));
				  }
				  // 本地dom渲染
				await this.setDomVideoStream("localdemo01",localStream)
				//回调监听
				this.onPcEvent(this.localRtcPc,callerId,calleeId)
				//创建offer
				let offer = await this.localRtcPc.createOffer({iceRestart:true});
				//设置offer未本地描述
				await this.localRtcPc.setLocalDescription(offer)
				//发送offer给被呼叫端
				let params = {"targetUid":calleeId,"userId":callerId,"offer":offer}
				this.linkSocket.emit("offer",params)
			},
			onPcEvent(pc,localUid,remoteUid){
				const that = this
				this.channel = pc.createDataChannel("chat");
				pc.ontrack = function(event) {
					console.log(event)
					that.setRemoteDomVideoStream("remoteVideo01",event.track)
				};
				pc.onnegotiationneeded = function(e){
					console.log("重新协商",e)
				}
				pc.ondatachannel = function(ev) {
				  console.log('Data channel is created!');
				  ev.channel.onopen = function() {
				    console.log('Data channel ------------open----------------');
				  };
				  ev.channel.onmessage = function(data) {
				    console.log('Data channel ------------msg----------------',data);
					that.formInline.rtcmessageRes = data.data
				  };
				  ev.channel.onclose = function() {
				    console.log('Data channel ------------close----------------');
				  };
				};
				pc.onicecandidate = (event) => {
				  if (event.candidate) {
					that.linkSocket.emit('candidate',{'targetUid':remoteUid,"userId":localUid,"candidate":event.candidate})
				  } else {
				    /* 在此次协商中,没有更多的候选了 */
					console.log("在此次协商中,没有更多的候选了")
				  }
				}
			},
			sendMessageUserRtcChannel(){
				if(!this.channel){
					this.$message.error("请先建立webrtc连接")
				}
				this.channel.send(this.formInline.rtcmessage)
				this.formInline.rtcmessage = undefined
			},
			async onRemoteOffer(fromUid,offer){
				this.localRtcPc.setRemoteDescription(offer)
				let answer = await this.localRtcPc.createAnswer();
				await this.localRtcPc.setLocalDescription(answer);
				let params = {"targetUid":fromUid,"userId":getParams("userId"),"answer":answer}
				this.linkSocket.emit("answer",params)
			},
			async onRemoteAnswer(fromUid,answer){
				await this.localRtcPc.setRemoteDescription(answer);
			},
			sendMsgToOne(event,params){
				
			},
			
			changeBitRate(){
				console.log(this.localRtcPc);
				const senders = this.localRtcPc.getSenders();
				const send = senders.find((s) => s.track.kind === 'video')
				const parameters = send.getParameters();
				parameters.encodings[0].maxBitrate = 1 * 1000 * 1024;
				send.setParameters(parameters);
			}
			,
			/**
			 * 打开或关闭摄像头
			 */
			openVideoOrNot(){
				const senders = this.localRtcPc.getSenders();
				const send = senders.find((s) => s.track.kind === 'video')
				send.track.enabled = !send.track.enabled //控制视频显示与否
			},
			/**
			 * 获取屏幕分享的媒体流
			 * @author suke
			 * @returns {Promise<void>}
			 */
			async getShareMedia(){
			    const constraints = {
			        video:{width:1920,height:1080},
					audio:true
			    };
			    if (window.stream) {
			        window.stream.getTracks().forEach(track => {
			            track.stop();
			        });
			    }
			    return await navigator.mediaDevices.getDisplayMedia(constraints).catch(handleError);
			},
			streamInfo(domId){
				let video = document.getElementById(domId)
				console.log(video.srcObject)
			},
			getStats(){
				const that = this
				const senders = this.localRtcPc.getSenders();
				const send = senders.find((s) => s.track.kind === 'video')
				console.log(send.getParameters().encodings);
				let lastResultForStats;//上次计算结果
				setInterval(() => {
					that.localRtcPc.getStats().then(res => {
						res.forEach(report => {
							let bytes;
						  let headerBytes;
						  let packets;
						  // console.log(report)
						  //出口宽带 outbound-rtp  入口宽带 inbound-rtp
						  if (report.type === 'outbound-rtp' && report.kind ==='video') {
								const now = report.timestamp;
								bytes = report.bytesSent;
								headerBytes = report.headerBytesSent;
						        packets = report.packetsSent;	
								console.log(bytes,headerBytes,packets)
							if (lastResultForStats && lastResultForStats.has(report.id)) {
								let bf = bytes-lastResultForStats.get(report.id).bytesSent
								let hbf = headerBytes - lastResultForStats.get(report.id).headerBytesSent
								let pacf = packets - lastResultForStats.get(report.id).packetsSent
								let t = now - lastResultForStats.get(report.id).timestamp
								// calculate bitrate
							  const bitrate = Math.floor(8 * bf/t);
							  const headerrate = Math.floor(8 * hbf/t);
							  const packetrate = Math.floor(1000 * pacf/t);
							  console.log(`Bitrate ${bitrate} kbps, overhead ${headerrate} kbps, ${packetrate} packets/second`);
								}
							}
						})
						lastResultForStats = res
					})
				},4000)
				
			},
			
			

		}
		
	}
</script>

<style scoped>
	#localdemo01{
		width: 300px;
		height: 200px;
		
	}
	#remoteVideo01{
		width: 300px;
		height: 200px;
	}
</style>
const {hSet,hGetAll,hDel}  = require('./redis')
const {getMsg,getParams} = require('./common')

const http = require('http')
var fs=require('fs');
var express = require('express');
const { log } = require('console');
var app = express();



//http server
app.use(express.static('./dist'));
app.use(function (req, res,next) {
  res.sendfile('./dist/index.html');  //路径根据自己文件配置
});
var server=http.createServer(app)
//socket server
let io = require('socket.io')(server,{allowEIO3:true});

//自定义命令空间  nginx代理 /mediaServerWsUrl { http://xxxx:18080/socket.io/ }
// io = io.of('mediaServerWsUrl')

server.listen(18080, async() => {
  console.log('服务器启动成功 *:18080');
});


io.on('connection', async (socket) => {
	await onListener(socket)
});



const userMap = new Map() // user - > socket
const roomKey = "meeting-room::"


/**
 * DB data
 * @author suke
 * @param {Object} userId
 * @param {Object} roomId
 * @param {Object} nickname
 * @param {Object} pub
 */
async function getUserDetailByUid(userId,roomId,nickname,pub){
	let res = JSON.stringify(({"userId":userId,"roomId":roomId,"nickname":nickname,"pub":pub}))
	return res
}

/**
 * 监听
 * @param {Object} s
 */
async function onListener(s){
	let url = s.client.request.url
	let userId = getParams(url,'userId')
	let roomId = getParams(url,'roomId')
	let nickname = getParams(url,'nickname')
	let pub = getParams(url,'pub')
	console.log("client uid:"+userId+" roomId: "+roomId+" 【"+nickname+"】online ")
	//user map
	userMap.set(userId,s)
	//room cache
	if(roomId){
		await hSet(roomKey+roomId,userId, await getUserDetailByUid(userId,roomId,nickname,pub))
		console.log("roomId",roomId)
		oneToRoomMany(roomId,getMsg('join',userId+ ' join then room',200,{userId:userId,nickname:nickname}))
	}

	s.on('msg', async (data) => {
		  console.log("msg",data)
		  await oneToRoomMany(roomId,data)
	});

	s.on('disconnect', () => {
		  console.log("client uid:"+userId+" roomId: "+roomId+" 【"+nickname+"】 offline ")
		  userMap.delete(userId)
		  if(roomId){
			  hDel(roomKey+roomId,userId)
			  oneToRoomMany(roomId,getMsg('leave',userId+' leave the room ',200,{userId:userId,nickname:nickname}))
		  }
	});

	s.on('roomUserList', async (data) => {
		// console.log("roomUserList msg",data)
		s.emit('roomUserList',await getRoomOnlyUserList(data['roomId']))
	})
	s.on('call',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('call',"远程呼叫",200,data))
	})
	s.on('candidate',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('candidate',"ice candidate",200,data))
	})
	s.on('offer',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('offer',"rtc offer",200,data))
	})
	s.on('answer',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('answer',"rtc answer",200,data))
	})
	s.on('applyMic',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('applyMic',"apply mic",200,data))
	})
	s.on('acceptApplyMic',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('acceptApplyMic',"acceptApplyMic mic",200,data))
	})
	s.on('refuseApplyMic',(data) => {
		let targetUid = data['targetUid']
		oneToOne(targetUid,getMsg('refuseApplyMic',"refuseApplyMic mic",200,data))
	})
	
}

/**
 * ono to one
 * @author suke
 * @param {Object} uid
 * @param {Object} msg
 */
function oneToOne(uid,msg){
	let s = userMap.get(uid)
	if(s){
		s.emit('msg',msg)
	}else{
		console.log(uid+"用户不在线")
	}
}

/**
 * 获取房间用户列表(k-v) 原始KV数据
 * @author suke
 * @param {Object} roomId
 */
async function getRoomUser(roomId){
	return await hGetAll(roomKey+roomId)
}

/**
 * 获取房间用户列表(list)
 * @author suke
 * @param {Object} roomId
 */
async function getRoomOnlyUserList(roomId){
	let resList = []
	let uMap = await hGetAll(roomKey+roomId)
	for(const key in uMap){
		let detail = JSON.parse(uMap[key])
		resList.push(detail);
	}
	return resList
}



/**
 * broadcast
 * @author suc
 * @param {Object} roomId
 * @param {Object} msg
 */
async function oneToRoomMany(roomId,msg){
	let uMap = await getRoomUser(roomId)
	for(const uid in uMap){
	  oneToOne(uid,msg)
	}
}