「手把手」实现网页视频聊天功能

在前面的两篇文章里,我们通过laravel-echo和laravel-websocket实现了网页和服务器间的双向通信功能,网页和网页之前的通信也很简单,原 文档 中也有说明,就不再单写文章了。但这些都是 文字 形式的通信,现在我们来个升级,将文字形式改为 音频/视频 的形式,也就是类似于微信的视频通话功能(鉴于本人电脑没有摄像头/麦克风等设备,就先以电脑屏幕作为视频源吧)。
新手必看
主要流程
创建页面
我们在routes/web.php中加入一个新的路由:
Route::get('/rtc-local', function () { return view('web-rtc-local'); }); 并在resources/views目录下新建web-rtc-local.blade.php视图文件,内容如下:
<!DOCTYPE html> <html> <head> <title>WebRTC Local</title> <!-- 本地双窗口测试时会报错,但不影响,加上这个可以去掉报错 --> <script src="https://cdn.jsdelivr.net/npm/adapterjs@0.15.5/publish/adapter.min.js"></script> </head> <body> <video class="remote-video" controls autoplay playsinline style="max-width: 800px;"></video> <video class="local-video" controls autoplay playsinline style="max-width: 800px;"></video> <script type="text/javascript"> // TODO </script> </body> </html> 本地窗口播放
现在我们先尝试下获取屏幕的视屏流,并在 video 标签中进行播放。在 script 标签中加入以下代码:
// 从可用设备列表中获取屏幕设备,并将视频流放入 video 标签内进行播放 navigator.mediaDevices.getDisplayMedia({video: true}).then((stream) => { document.querySelector('.local-video').srcObject = stream; }); 大功告成,我们来看下效果,打开浏览器,输入http://<your.host>/rtc-local回车:

哈?什么玩意?啥都没有啊,而且还有个「错误」?淡定下,这里报错是因为 WebRTC 标准中规定,只有https、localhost和通过file:///协议打开的本地文件才可访问媒体设备列表,这也是出于安全考虑。那么既然知道原因了,解决这个「错误」就迎刃而解了,只需要把你的域名加一个证书,然后改为https就可以了。
注意:通过 MediaDevices.getUserMedia() 获取用户多媒体权限时,需要注意其只工作于以下三种环境。 其他情况下,比如在一个 HTTP 站点上,navigator.mediaDevices 的值为 undefined:
- localhost 域
- 开启了 HTTPS 的域
- 使用 file:/// 协议打开的本地文件
哈?还要安装证书?我只是想在自己的服务器上简单测试下,好麻烦啊有木有!好吧,针对自己测试的情况,还有个更简单的方案,这需要你使用Chrome浏览器才可以。在Chrome浏览器的地址栏中输入chrome://flags/#unsafely-treat-insecure-origin-as-secure后回车,会出现黄色标记的选项:

把你的域名写入输入框,然后将后面的Disabled改为Enabled,此操作需要重启Chrome浏览器。重启后,再输入http://<your.host>/rtc-local看下效果:

浏览器会弹出一个窗口,这个窗口提供了浏览器可以抓取到的屏幕,包括完整的屏幕(我有两个显示器,所以会是两个)、开启的各程序窗口、单独的标签页等。我们只需点击需要抓取的屏幕,然后点击右下角的分享,即可看到神奇的画中画效果:


本地视频流转换
上面实现了有意思的小功能,我们着实小兴奋了一把。接下来,我们要向两个视频窗口间的「协商」过程更深入一步了。在写代码之前,我们先看个协商过程的时序图,理解下协商过程:

图片转自 这里,原文大家也可以看看。结下来,我们重新写 js 代码:
// 定义本地媒体流对象 var localStream = null; // PeerConnection:对等连接,为通信的双方提供一个相同的接口/协议 var pc1 = null; var pc2 = null; // 获取 DOM 对象 var localVideo = document.querySelector('.local-video'); var remoteVideo = document.querySelector('.remote-video'); var call = function(){ // 创建对等连接 pc1 = new RTCPeerConnection(); pc2 = new RTCPeerConnection(); // 设置回调方法 pc1.onicecandidate = function(e){ // 向 ICE 代理添加远程候选对象 pc2.addIceCandidate(e.candidate); } pc2.onicecandidate = function(e){ pc1.addIceCandidate(e.candidate); } // 设置回调方法 pc2.ontrack = function(e){ remoteVideo.srcObject = e.streams[0]; } // 获取全部的媒体列表,并将这些媒体的源改为本地媒体流对象 localStream.getTracks().forEach((track)=>{ pc1.addTrack(track,localStream); }); // 要求浏览器正确构建一个 SDP(会话描述协议)对象,该对象代表发起方的媒体和要传达给远程方的功能 pc1.createOffer().then(sendOffer).catch((err) => { console.log(err); }); } var sendOffer = function(description){ // 将这个 SDP 对象设为 pc1 的本地会话描述协议 pc1.setLocalDescription(description); // 将这个新描述协议设为 pc2 的远程连接描述协议 pc2.setRemoteDescription(description); // 根据 pc2 新的远程连接描述协议生成应答会话描述协议 pc2.createAnswer().then(sendAnswer).catch((err) => { console.log(err); }); } // 这里 setLocalDescription 和 setRemoteDescription 所指定的 pc 对象和上面的正好相反,意思就是双方都确认了对方的远程链接描述协议,达成协商 var sendAnswer = function(description){ // 将这个 SDP 对象设为 pc2 的本地会话描述协议 pc2.setLocalDescription(description); // 将这个新描述协议设为 pc1 的远程连接描述协议 pc1.setRemoteDescription(description); } navigator.mediaDevices.getDisplayMedia({video: true}).then((stream) => { // document.querySelector('.local-video').srcObject = stream; // 将视频流存入变量 localStream = stream; // 将视频流放入页面中的「本地视频」窗口 localVideo.srcObject = localStream; // 本地双窗口测试 call(); }) .catch((e) => { console.log(e); }); 保存文件,然后再次访问页面。此时会显示两个视频窗口,内容都是一样的。虽然内容一样,但一定要理解他们之间是通过 WebRTC 规范进行的媒体流交互的:

远程视频流转换
最后是重头戏了,我们来实现远程的视频流交互。这里需要用到 websocket 通信,所以你需要自己搭建好一个可以正常通信的 websocket 服务,这里就不再赘述了。因为我的项目中已经使用了laravel-echo和laravel-websockets这两个软件包,所以就继续用他们吧。
添加频道
我们打开routes/channels.php文件,在里面新增一个chatting-room频道:
Broadcast::channel('chatting-room', function ($user) { return $user; }); 将项目中config/websockets.php文件内的enable_client_messages(打开客户端事件)参数设为true:
return [ 'apps' => [ [ ... 'enable_client_messages' => true, ... ]; ]; 记得.env文件中的相关变量要赋值:
BROADCAST_DRIVER=pusher PUSHER_APP_ID=joker PUSHER_APP_KEY=joker PUSHER_APP_SECRET=joker PUSHER_APP_CLUSTER=mt1 注意:改完参数记得重启
laravel-websockets服务。
添加登陆模块
因为远程链接时使用laravel-echo的join方法,这个方法需要进行授权验证,也就是当前必须是登陆状态才可以(如果你你已经实现了登陆接口,则可以跳过此步骤)。为了省去自己写登陆页面和登陆接口,我们安装官方文档中推荐的 Jetstream 软件包,就可以直接进行登陆操作了。我们在项目根目录下执行:
// 安装软件包 > composer require laravel/jetstream // 发布配置文件 > php artisan jetstream:install livewire // 安装相关依赖 > npm install // 编译前端 > npm run dev 此时,我们再输入http://<your.host>/login便可以看到登陆页了:

输入http://<your.host>/register是注册账号页面,我们随便注册两个账号即可:

实现
修改 html 的 header,加入laravel-echo和pusher插件:
<head> <title>WebRTC Websocket</title> <!-- 引入laravel-echo工具,其实使用Larave自带的也可以。但是,使用自带的还需要用到node前端构建工具,我这里只简单的演示后端实现过程,就不用node了 --> <script src="https://cdn.jsdelivr.net/npm/laravel-echo@1.10.0/dist/echo.iife.js"></script> <!-- 引入pusher工具,pusher是Laravel-echo底层,Laravel-echo是pusher的一层封装 --> <script src="https://cdn.jsdelivr.net/npm/pusher-js@7.0.3/dist/web/pusher.min.js"></script> </head> 为了区分明确,我们调整一下本地显示窗口的大小为 400px:
<video class="remote-video" controls autoplay playsinline style="max-width: 800px;"></video> <video class="local-video" controls autoplay playsinline style="max-width: 400px;"></video> 接下来我们需要重写一下 js 代码:
// 通信方式:websocket,使用 Laravel-echo + Laravel-websockets // 为了方便,使用 whisper() 方法进行广播 var wsHost = location.hostname; var wsPort = 2020; var laravelEcho = new Echo({ broadcaster: 'pusher', key: 'joker', wsHost: wsHost, wsPort: wsPort, forceTLS: false, enabledTransports: ['ws'], });; // 本地媒体流对象,这里说的是「媒体」流,其包括「视频」流和「音频」流 var localStream = null; // PeerConnection:对等连接,为通信的双方提供一个相同的接口/协议 var pc = null; // 对等连接配置 var pcConfig = { 'iceServers':[ { 'urls':'stun:'+wsHost+':'+wsPort, } ] } // 本地视频窗口对象 var localVideo = document.querySelector('.local-video'); // 远程视频窗口对象 var remoteVideo = document.querySelector('.remote-video'); // 发起媒体连接请求 var call = function(){ if(pc){ var options = { offerToReceiveAudio: true, offerToReceiveVideo: true, } // 要求浏览器正确构建一个 SDP(会话描述协议)对象,该对象代表发起方的媒体和要传达给远程方的功能 pc.createOffer(options).then((description) => { // 将这个 SDP 对象设为本地会话描述协议 pc.setLocalDescription(description); // 并将这个会话描述协议广播给当前房间的所有人 laravelEcho.join('chatting-room').whisper('VS', description); }) .catch((err) => { console.log(err); }); } } // 创建一个对等连接 var createPeerConnection = function(){ if(!pc){ // 初始化对等连接 pc = new RTCPeerConnection(pcConfig); // 设置回调方法,每当浏览器内部的 ICE 协议机器将新候选者提供给本地对等方(调用 setLocalDescription() 方法)时,就会触发 onicecandidate 处理程序。 pc.onicecandidate = function(e){ if(e.candidate){ // 如果事件中有「候选人」参数,则通过 websocket 广播将「候选人」信息给当前房间内的所有人。 laravelEcho.join('chatting-room').whisper('VS', { type:'candidate', label:e.candidate.sdpMLineIndex, candidate:e.candidate.candidate }); } } // 设置回调方法,当调用 addTrack() 方法时会触发 ontrack 处理程序。 pc.ontrack = function(e){ // 将事件中的远程媒体流赋值给「远程视频窗口」并自动播放 remoteVideo.srcObject = e.streams[0]; } if(localStream){ // 获取全部的媒体列表,并将这些媒体的源改为本地媒体流对象 localStream.getTracks().forEach((track)=>{ pc.addTrack(track,localStream); }); } } } // 关闭对等连接 var closePeerConnection = function (){ if(pc){ pc.close(); pc = null; } } // 关闭本地媒体流 var closeLocalMedia = function (){ if (localStream&&localStream.getTracks()) { localStream.getTracks().forEach((track)=>{ track.stop(); }); } localStream = null; } // 开始连接 var conn = function(){ // 选择房间 laravelEcho.join('chatting-room') .here((user) => { // 当前用户加入频道时触发,返回当前频道中在线的人员列表 console.log("here"); console.table(user); // 创建对等连接 createPeerConnection(); }) .joining((user) => { // 其他人加入频道时会触发,返回加入人的信息 console.log("joining"); // 创建对等连接 createPeerConnection(); // 发起媒体连接请求 call(); }) .leaving((user) => { // 其他人离开频道时会触发,返回离开人的信息 console.log("leaving"); // 关闭对等连接 closePeerConnection(); // closeLocalMedia(); }) .listenForWhisper('VS', (data) => { // 监听事件,事件名称自定义,触发和监听保持一致即可。 if(data){ if(data.type === 'offer'){ // 如果会话描述协议的类型 (type) 是媒体连接请求 // 通过会话描述协议创建一个新的 RTCSessionDescription 描述协议 // 并将这个新描述协议设为远程连接描述协议 pc.setRemoteDescription(new RTCSessionDescription(data)); // 根据新的远程连接描述协议生成应答会话描述协议 pc.createAnswer().then((description) => { // 将这个应答会话描述协议设为本地会话描述协议 pc.setLocalDescription(description); // 并将这个应答会话描述协议广播给当前房间的所有人 laravelEcho.join('chatting-room').whisper('VS', description); }) .catch((err) => { console.log(err); }); } else if(data.type === 'answer'){ // 如果会话描述协议的类型 (type) 是应答 // 通过应答会话描述协议创建一个新的 RTCSessionDescription 描述协议 // 并将这个新描述协议设为远程连接描述协议(经过一问一答,双方的远程连接描述协议已经保持一致) pc.setRemoteDescription(new RTCSessionDescription(data)); } else if(data.type === 'candidate'){ // 如果会话描述协议的类型 (type) 是候选人 // 向 ICE 代理添加远程候选对象 pc.addIceCandidate(new RTCIceCandidate({ sdpMLineIndex:data.label, candidate:data.candidate })); } else { console.log('the message is invalid!',data) } } }); } // 调用 Chrome 浏览器的 API,获取媒体设备(显示器、摄像头、麦克风等) navigator.mediaDevices // 获取屏幕设备的图像和音频 .getDisplayMedia({ video: true, audio: true, }) .then((stream) => { // 将媒体流存入变量 localStream = stream; // 将媒体流放入页面中的「本地视频」窗口 localVideo.srcObject = localStream; // 这个函数的调用时机特别重要,一定要在获取到本地媒体流之后再调用,否则会出现绑定失败的情况 conn(); }) .catch((e) => { console.log(e); }); 注意:使用
join方法需要进行频道授权,也就是你需要先登陆到应用里,有会话状态后才能加入成功。whisper()这个方法需要将enable_client_messages参数设为true才可以使用。
我们打开登陆页http://<your.host>/login,输入最开始注册的账号,登陆成功后,再打开新的标签页,输入http://<your.host>/rtc-local回车。然后我们再打开一个「无痕模式」的Chrome浏览器(Mac快捷键:Shift+Command+N),同样是先登陆一个用户,然后打开http://<your.host>/rtc-local页面,此时,两个浏览器中的视频窗口便会连通,实现远程视频流播放。我们再来看下效果:


注意:如果没有看到视频窗口变化,打开浏览器的开发者工具,在网络请求中找到
/broadcasting/auth接口,看是否授权成功。必须是登陆过的用户才会授权通过。
再加「亿」点点细节(以下效果需要自己简单修改下代码实现,鉴于动图太大没法上传,就先用图片吧)。因为代码与上面差别不大,就不再写一遍了,想看代码的可以点 这里:








一旦双方达成协商,那么,他们之间便可直接进行通信。此时,即使我们停止 websocket 服务,双方仍然是连通的。因为 websocket 只是作为协商时的通信工具(这个工具可以换成普通的 ajax 请求实现),并不是媒体流的接收和转发服务器。文章中实现了类似远程桌面的功能,其实只要将代码中的navigator.mediaDevices.getDisplayMedia改为navigator.mediaDevices.getUserMedia即可调取摄像头和麦克风等设备,实现视频通话功能。奈何手头没有这些设备,没完全展示了,以后有机会再补充吧。
参考文章
- webRTC(十):webrtc 实现web端对端视频
- MediaDevices.getUserMedia` undefined 的问题
- WebRTC >RTCPeerConnection-建立连接的全过程
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
本来我打算写个帖子记录一下,看样子省事了😄
卧槽,666,刚想说玩一玩laravel-echo和websokect就看到这篇文章了,感谢!
666 学习了
mark火前留名
你真是个人才~
6的飞起
請問可以做成group chat 嗎?Mesh topology 的那種。
有點不太懂要怎麽轉,已經卡了兩星期,求指教。