WebRTC:一个视频聊天的简单例子
相关API简介
在前面的章节中,已经对WebRTC相关的重要知识点进行了介绍,包括涉及的网络协议、会话描述协议、如何进行网络穿透等,剩下的就是WebRTC的API了。
WebRTC通信相关的API非常多,主要完成了如下功能:
- 信令交换
- 通信候选地址交换
- 音视频采集
- 音视频发送、接收
相关API太多,为避免篇幅过长,文中部分采用了伪代码进行讲解。详细代码参考文章末尾,也可以在笔者的Github上找到,有问题欢迎留言交流。
信令交换
信令交换是WebRTC通信中的关键环节,交换的信息包括编解码器、网络协议、候选地址等。对于如何进行信令交换,WebRTC并没有明确说明,而是交给应用自己来决定,比如可以采用WebSocket。
发送方伪代码如下:
const pc = new RTCPeerConnection(iceConfig); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendToPeerViaSignalingServer(SIGNALING_OFFER, offer); // 发送方发送信令消息
接收方伪代码如下:
const pc = new RTCPeerConnection(iceConfig); await pc.setRemoteDescription(offer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendToPeerViaSignalingServer(SIGNALING_ANSWER, answer); // 接收方发送信令消息
候选地址交换服务
当本地设置了会话描述信息,并添加了媒体流的情况下,ICE框架就会开始收集候选地址。两边收集到候选地址后,需要交换候选地址,并从中知道合适的候选地址对。
候选地址的交换,同样采用前面提到的信令服务,伪代码如下:
// 设置本地会话描述信息 const localPeer = new RTCPeerConnection(iceConfig); const offer = await pc.createOffer(); await localPeer.setLocalDescription(offer); // 本地采集音视频 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; // 添加音视频流 mediaStream.getTracks().forEach(track => { localPeer.addTrack(track, mediaStream); }); // 交换候选地址 localPeer.onicecandidate = function(evt) { if (evt.candidate) { sendToPeerViaSignalingServer(SIGNALING_CANDIDATE, evt.candidate); } }
音视频采集
可以使用浏览器提供的getUserMedia
接口,采集本地的音视频。
const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream;
音视频发送、接收
将采集到的音视频轨道,通过addTrack
进行添加,发送给远端。
mediaStream.getTracks().forEach(track => { localPeer.addTrack(track, mediaStream); });
远端可以通过监听ontrack
来监听音视频的到达,并进行播放。
remotePeer.ontrack = function(evt) { const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = evt.streams[0]; }
完整代码
包含两部分:客户端代码、服务端代码。
1、客户端代码
const socket = io.connect('http://localhost:3000'); const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT'; const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT'; const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT'; const SERVER_USER_EVENT = 'SERVER_USER_EVENT'; const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; // 登录 const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS'; const SIGNALING_OFFER = 'SIGNALING_OFFER'; const SIGNALING_ANSWER = 'SIGNALING_ANSWER'; const SIGNALING_CANDIDATE = 'SIGNALING_CANDIDATE'; let remoteUser = ''; // 远端用户 let localUser = ''; // 本地登录用户 function log(msg) { console.log(`[client] ${msg}`); } socket.on('connect', function() { log('ws connect.'); }); socket.on('connect_error', function() { log('ws connect_error.'); }); socket.on('error', function(errorMessage) { log('ws error, ' + errorMessage); }); socket.on(SERVER_USER_EVENT, function(msg) { const type = msg.type; const payload = msg.payload; switch(type) { case SERVER_USER_EVENT_UPDATE_USERS: updateUserList(payload); break; } log(`[${SERVER_USER_EVENT}] [${type}], ${JSON.stringify(msg)}`); }); socket.on(SERVER_RTC_EVENT, function(msg) { const {type} = msg; switch(type) { case SIGNALING_OFFER: handleReceiveOffer(msg); break; case SIGNALING_ANSWER: handleReceiveAnswer(msg); break; case SIGNALING_CANDIDATE: handleReceiveCandidate(msg); break; } }); async function handleReceiveOffer(msg) { log(`receive remote description from ${msg.payload.from}`); // 设置远端描述 const remoteDescription = new RTCSessionDescription(msg.payload.sdp); remoteUser = msg.payload.from; createPeerConnection(); await pc.setRemoteDescription(remoteDescription); // TODO 错误处理 // 本地音视频采集 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; mediaStream.getTracks().forEach(track => { pc.addTrack(track, mediaStream); // pc.addTransceiver(track, {streams: [mediaStream]}); // 这个也可以 }); // pc.addStream(mediaStream); // 目前这个也可以,不过接口后续会废弃 const answer = await pc.createAnswer(); // TODO 错误处理 await pc.setLocalDescription(answer); sendRTCEvent({ type: SIGNALING_ANSWER, payload: { sdp: answer, from: localUser, target: remoteUser } }); } async function handleReceiveAnswer(msg) { log(`receive remote answer from ${msg.payload.from}`); const remoteDescription = new RTCSessionDescription(msg.payload.sdp); remoteUser = msg.payload.from; await pc.setRemoteDescription(remoteDescription); // TODO 错误处理 } async function handleReceiveCandidate(msg){ log(`receive candidate from ${msg.payload.from}`); await pc.addIceCandidate(msg.payload.candidate); // TODO 错误处理 } /** * 发送用户相关消息给服务器 * @param {Object} msg 格式如 { type: 'xx', payload: {} } */ function sendUserEvent(msg) { socket.emit(CLIENT_USER_EVENT, JSON.stringify(msg)); } /** * 发送RTC相关消息给服务器 * @param {Object} msg 格式如{ type: 'xx', payload: {} } */ function sendRTCEvent(msg) { socket.emit(CLIENT_RTC_EVENT, JSON.stringify(msg)); } let pc = null; /** * 邀请用户加入视频聊天 * 1、本地启动视频采集 * 2、交换信令 */ async function startVideoTalk() { // 开启本地视频 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; // 创建 peerConnection createPeerConnection(); // 将媒体流添加到webrtc的音视频收发器 mediaStream.getTracks().forEach(track => { pc.addTrack(track, mediaStream); // pc.addTransceiver(track, {streams: [mediaStream]}); }); // pc.addStream(mediaStream); // 目前这个也可以,不过接口后续会废弃 } function createPeerConnection() { const iceConfig = {"iceServers": [ {url: 'stun:stun.ekiga.net'}, {url: 'turn:turnserver.com', username: 'user', credential: 'pass'} ]}; pc = new RTCPeerConnection(iceConfig); pc.onnegotiationneeded = onnegotiationneeded; pc.onicecandidate = onicecandidate; pc.onicegatheringstatechange = onicegatheringstatechange; pc.oniceconnectionstatechange = oniceconnectionstatechange; pc.onsignalingstatechange = onsignalingstatechange; pc.ontrack = ontrack; return pc; } async function onnegotiationneeded() { log(`onnegotiationneeded.`); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // TODO 错误处理 sendRTCEvent({ type: SIGNALING_OFFER, payload: { from: localUser, target: remoteUser, sdp: pc.localDescription // TODO 直接用offer? } }); } function onicecandidate(evt) { if (evt.candidate) { log(`onicecandidate.`); sendRTCEvent({ type: SIGNALING_CANDIDATE, payload: { from: localUser, target: remoteUser, candidate: evt.candidate } }); } } function onicegatheringstatechange(evt) { log(`onicegatheringstatechange, pc.iceGatheringState is ${pc.iceGatheringState}.`); } function oniceconnectionstatechange(evt) { log(`oniceconnectionstatechange, pc.iceConnectionState is ${pc.iceConnectionState}.`); } function onsignalingstatechange(evt) { log(`onsignalingstatechange, pc.signalingstate is ${pc.signalingstate}.`); } // 调用 pc.addTrack(track, mediaStream),remote peer的 onTrack 会触发两次 // 实际上两次触发时,evt.streams[0] 指向同一个mediaStream引用 // 这个行为有点奇怪,github issue 也有提到 https://github.com/meetecho/janus-gateway/issues/1313 let stream; function ontrack(evt) { // if (!stream) { // stream = evt.streams[0]; // } else { // console.log(`${stream === evt.streams[0]}`); // 这里为true // } log(`ontrack.`); const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = evt.streams[0]; } // 点击用户列表 async function handleUserClick(evt) { const target = evt.target; const userName = target.getAttribute('data-name').trim(); if (userName === localUser) { alert('不能跟自己进行视频会话'); return; } log(`online user selected: ${userName}`); remoteUser = userName; await startVideoTalk(remoteUser); } /** * 更新用户列表 * @param {Array} users 用户列表,比如 [{name: '小明', name: '小强'}] */ function updateUserList(users) { const fragment = document.createDocumentFragment(); const userList = document.getElementById('login-users'); userList.innerHTML = ''; users.forEach(user => { const li = document.createElement('li'); li.innerHTML = user.userName; li.setAttribute('data-name', user.userName); li.addEventListener('click', handleUserClick); fragment.appendChild(li); }); userList.appendChild(fragment); } /** * 用户登录 * @param {String} loginName 用户名 */ function login(loginName) { localUser = loginName; sendUserEvent({ type: CLIENT_USER_EVENT_LOGIN, payload: { loginName: loginName } }); } // 处理登录 function handleLogin(evt) { let loginName = document.getElementById('login-name').value.trim(); if (loginName === '') { alert('用户名为空!'); return; } login(loginName); } function init() { document.getElementById('login-btn').addEventListener('click', handleLogin); } init();
2、服务端代码
// 添加ws服务 const io = require('socket.io')(server); let connectionList = []; const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT'; const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT'; const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT'; const SERVER_USER_EVENT = 'SERVER_USER_EVENT'; const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS'; function getOnlineUser() { return connectionList .filter(item => { return item.userName !== ''; }) .map(item => { return { userName: item.userName }; }); } function setUserName(connection, userName) { connectionList.forEach(item => { if (item.connection.id === connection.id) { item.userName = userName; } }); } function updateUsers(connection) { connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); } io.on('connection', function (connection) { connectionList.push({ connection: connection, userName: '' }); // 连接上的用户,推送在线用户列表 // connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(connection); connection.on(CLIENT_USER_EVENT, function(jsonString) { const msg = JSON.parse(jsonString); const {type, payload} = msg; if (type === CLIENT_USER_EVENT_LOGIN) { setUserName(connection, payload.loginName); connectionList.forEach(item => { // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(item.connection); }); } }); connection.on(CLIENT_RTC_EVENT, function(jsonString) { const msg = JSON.parse(jsonString); const {payload} = msg; const target = payload.target; const targetConn = connectionList.find(item => { return item.userName === target; }); if (targetConn) { targetConn.connection.emit(SERVER_RTC_EVENT, msg); } }); connection.on('disconnect', function () { connectionList = connectionList.filter(item => { return item.connection.id !== connection.id; }); connectionList.forEach(item => { // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(item.connection); }); }); });
写在后面
WebRTC的API非常多,因为WebRTC本身就比较复杂,随着时间的推移,WebRTC的某些API(包括某些协议细节)也在改动或被废弃,这其中也有向后兼容带来的复杂性,比如本地视频采集后加入传输流,可以采用 addStream 或 addTrack 或 addTransceiver,再比如会话描述版本从plan-b迁移到unified-plan。
建议亲自动手撸一遍代码,加深了解。
相关链接
2019.08.02-video-talk-using-webrtc
https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
java如何跳出for循环?
java跳出for循环的方法 break continue return break语句 break用于完全结束一个循环,跳出循环体。不管是哪种循环,一旦在循环体中遇到break,系统将完全结束循环,开始执行循环之后的代码。 break不仅可以结束其所在的循环,还可结束其外层循环。此时需要在break后紧跟一个标签,这个标签用于标识一个外层循环。Java中的标签就是一个紧跟着英文冒号(:)的标识符。且它必须放在循环语句之前才有作用。 continue语句 continue语句的作用是跳过本次循环体中余下尚未执行的语句,立即进行下一次的循环条件判定,可以理解为仅结束本次循环。 注意:continue语句并没有使整个循环终止。 continue的功能和break有点类似,区别是continue只是中止本次循环,接着开始下一次循环。而break则是完全中止循环。break是结束整个循环体,continue是结束单次循环 return语句 return关键字并不是专门用于跳出循环的,return的功能是结束一个方法。 一旦在循环体内执行到一个return语句,return语句将会结束该方法,循...
- 下一篇
Java描述设计模式(19):模板方法模式
本文源码:GitHub·点这里 || GitEE·点这里 一、生活场景 通常一款互联网应用的开发流程如下:业务需求,规划产品,程序开发,测试交付。现在基于模板方法模式进行该过程描述。 public class C01_InScene { public static void main(String[] args) { DevelopApp developApp = new DevelopApp() ; developApp.templateMethod() ; } } /** * 软件开发抽象类 */ abstract class SoftDevelop { public void templateMethod(){ //调用基本方法 doBiz (); doProduct(); doDevelop(); doTest(); } public abstract void doBiz () ; public abstract void doProduct () ; public abstract void doDevelop () ; public abstract void doTes...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- 设置Eclipse缩进为4个空格,增强代码规范
- SpringBoot2整合Redis,开启缓存,提高访问速度
- Windows10,CentOS7,CentOS8安装MongoDB4.0.16
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- MySQL8.0.19开启GTID主从同步CentOS8
- Hadoop3单机部署,实现最简伪集群
- CentOS8编译安装MySQL8.0.19
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- SpringBoot2整合Thymeleaf,官方推荐html解决方案