服务端与信令
只有虽然说WebRTC支持P2P,但是需要有一台信令服务器来交换双方的SDP,现在我们就来用Node实现一个信令服务器。
这里选用阿里开源的MidwayJS服务端框架,你可以选择更加轻量级的Express或者Koa框架,都可以通过第三方中间件来实现相同的功能,我这里选择Midway完全是个人偏好。
如果感兴趣可以看一下Midway的文档,或者你钟爱Express可以使用Express+Express-ws来实现完全相同的效果。
客户端能力:
- 提供页面HTML;
- SDP交换;
使用ejs来进行页面渲染
使用模板引擎中间件来进行ejs模板的渲染工作,将路由根目录绑定到页面渲染
这一块相对简单,可以在使用的node框架的官网或者社区找到中间件的文档,根据文档进行配置,然后就可以开始编码工作了(代码非常简单,因为中间件帮我们处理了大部分事情)
@Provide() @Controller('/') export class HomeController { @Inject() ctx: Context; @Get('/') async home() { await this.ctx.render('home.ejs'); } } 复制代码
使用SocketIO作为socker-server来进行SDP交换
socketIO作为一个非常出名的socket框架,其内部提供了非常丰富的API,足够支撑起我们日常开发的大部分需要,这里具体的配置方法就不详写了,可以去官网了解,或许以后我会出相关的教程。
@Provide() @WSController('/') export class IndexSocketController { @Inject() ctx: Context; @App() socket: Application; @OnWSConnection() @WSEmit('connection') // 连接时触发,向客户端提交connection事件 async onConnectionMethod() { this.ctx.logger.info('on client connect', this.ctx.id); return 'connection'; } @OnWSMessage('join') // 收到消息标识为join时执行 async joinRoom(roomId: string, user: string) { if ((await this.ctx.to(roomId).allSockets()).size >= 2) { // 如果房间内的用户数量大于等于二,不可以再加入新的成员 // 并向客户端提交full事件 this.ctx.emit('full', `${roomId} is full!`); } else { await this.ctx.join(roomId); this.ctx.to(roomId).emit('join', `user[ ${user} ] join this room!`); // 加入房间之后向房间内其他成员提交join事件 // 向用户自身提交joined事件 this.ctx.emit('joined', `join in ${roomId}!`); } } @OnWSMessage('quit') @WSEmit('result') async quitRoom(roomId: string, user: string) { await this.ctx.leave(roomId); this.ctx.to(roomId).emit('quit', `user[ ${user} ] quit this room!`); return 'quit success'; } @OnWSMessage('call') async call(roomId, data) { this.ctx.to(roomId).emit('sdp', data); } } 复制代码
(用注解开发真的爽“死”了)
客户端
了解了之前的WebRTC相关API,并且有了信令服务器的加持,我们就可以来进行视频互动的开发了。
为了简化,我们直接就使用模板引擎来渲染视图,不再去单独使用前端框架创建项目了。部分代码借鉴了github的一个开源项目
首先来列举一下客户端的能力
- 基本的音视频通话;
- 录屏;
音视频聊天
进行音视频聊天又分为几个步骤,首先需要初始化ICE
// 初始化ICE const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; !PeerConnection && message.error('浏览器不支持WebRTC!'); const peer = new PeerConnection(); peer.ontrack = e => { if (e && e.streams) { // message 是自定义的日志工具 message.log('收到对方音频/视频流数据...'); remoteVideo.srcObject = e.streams[0]; } }; peer.onicecandidate = e => { if (e.candidate) { message.log('搜集并发送候选人'); socket.emit('call', roomId, JSON.stringify({ type: 'ice', iceCandidate: e.candidate })); } else { message.log('候选人收集完成!'); } }; 复制代码
然后需要进行交换SDP,这里需要使用Socket来完成
const socket = io('http://localhost:7001') socket.on('connect_error', () => { // 连接socket失败 message.error('socket通道初始化失败') }) // 消息处理 socket.on('connection', () => { // 连接之后加入房间 socket.emit('join', roomId, username) }) socket.on('sdp', e => { const { type, sdp, iceCandidate } = JSON.parse(e) if (type === 'answer') { peer.setRemoteDescription(new RTCSessionDescription({ type, sdp })); } else if (type === 'ice') { peer.addIceCandidate(iceCandidate); } else if (type === 'offer') { const resolve = confirm('接到视频请求,是否接听') resolve && startLive(new RTCSessionDescription({ type, sdp })); } }) 复制代码
当双方都添加了对端的SDP之后就可以开始视频互动了,这里同一封装为startLive方法
async function startLive(offerSdp) { button.style.display = 'none' let stream; try { message.log('尝试调取本地摄像头/麦克风'); stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); message.log('摄像头/麦克风获取成功!'); localVideo.srcObject = stream; } catch { message.error('摄像头/麦克风获取失败!'); return; } message.log(`------ WebRTC 流程开始 ------`); message.log('将媒体轨道添加到轨道集'); stream.getTracks().forEach(track => { peer.addTrack(track, stream); }); if (!offerSdp) { message.log('创建本地SDP'); const offer = await peer.createOffer(); await peer.setLocalDescription(offer); message.log(`传输发起方本地SDP`); socket.emit('call', roomId, JSON.stringify(offer)); } else { message.log('接收到发送方SDP'); await peer.setRemoteDescription(offerSdp); message.log('创建接收方(应答)SDP'); const answer = await peer.createAnswer(); message.log(`传输接收方(应答)SDP`); socket.emit('call', roomId, JSON.stringify(answer)); await peer.setLocalDescription(answer); } } 复制代码
录屏
实现了视频聊天之后我们来添加录屏的功能
之前我们已经介绍了录屏相关的API,这里我们将其添加进我们现有的代码中即可
let buffer, mediaRecorder; //当该函数被触发后,将数据压入到blob中 function handleDataAvailable(e) { if (e && e.data && e.data.size > 0) { buffer.push(e.data); } } // 开始录制 function startRecord() { buffer = []; //设置录制下来的多媒体格式 var options = { mimeType: 'video/webm;codecs=vp8' } //判断浏览器是否支持录制 if (!MediaRecorder.isTypeSupported(options.mimeType)) { message.error(`${options.mimeType} is not supported!`); return; } try { //创建录制对象 // stream 需要从将视频流保留到全局 mediaRecorder = new MediaRecorder(stream, options); } catch (e) { message.error('Failed to create MediaRecorder:', e.message); return; } //当有音视频数据来了之后触发该事件 mediaRecorder.ondataavailable = handleDataAvailable; //开始录制 mediaRecorder.start(10); } // 停止录制 function stopRecord() { mediaRecorder.stop(); } // 将录制的视频下载到本地 function downloadRecord() { var blob = new Blob(buffer, { type: 'video/webm' }); var url = window.URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.style.display = 'none'; a.download = new Date().getTime() + '.webm'; a.click(); } 复制代码
这里只是做了演示,需要根据需求定制录屏的内容