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

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

在前面的两篇文章里,我们通过laravel-echolaravel-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回车:

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

哈?什么玩意?啥都没有啊,而且还有个「错误」?淡定下,这里报错是因为 WebRTC 标准中规定,只有httpslocalhost和通过file:///协议打开的本地文件才可访问媒体设备列表,这也是出于安全考虑。那么既然知道原因了,解决这个「错误」就迎刃而解了,只需要把你的域名加一个证书,然后改为https就可以了。

注意:通过 MediaDevices.getUserMedia() 获取用户多媒体权限时,需要注意其只工作于以下三种环境。 其他情况下,比如在一个 HTTP 站点上,navigator.mediaDevices 的值为 undefined:

  • localhost 域
  • 开启了 HTTPS 的域
  • 使用 file:/// 协议打开的本地文件

哈?还要安装证书?我只是想在自己的服务器上简单测试下,好麻烦啊有木有!好吧,针对自己测试的情况,还有个更简单的方案,这需要你使用Chrome浏览器才可以。在Chrome浏览器的地址栏中输入chrome://flags/#unsafely-treat-insecure-origin-as-secure后回车,会出现黄色标记的选项:

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

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

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

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

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

本地视频流转换

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

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

图片转自 这里,原文大家也可以看看。结下来,我们重新写 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 规范进行的媒体流交互的:

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

远程视频流转换

最后是重头戏了,我们来实现远程的视频流交互。这里需要用到 websocket 通信,所以你需要自己搭建好一个可以正常通信的 websocket 服务,这里就不再赘述了。因为我的项目中已经使用了laravel-echolaravel-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-echojoin方法,这个方法需要进行授权验证,也就是当前必须是登陆状态才可以(如果你你已经实现了登陆接口,则可以跳过此步骤)。为了省去自己写登陆页面和登陆接口,我们安装官方文档中推荐的 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-echopusher插件:

<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页面,此时,两个浏览器中的视频窗口便会连通,实现远程视频流播放。我们再来看下效果:

「手把手」laravel-echo + laravel-websockets = 网页视频聊天

2021-04-13 152726.gif

注意:如果没有看到视频窗口变化,打开浏览器的开发者工具,在网络请求中找到/broadcasting/auth接口,看是否授权成功。必须是登陆过的用户才会授权通过。

再加「亿」点点细节(以下效果需要自己简单修改下代码实现,鉴于动图太大没法上传,就先用图片吧)。因为代码与上面差别不大,就不再写一遍了,想看代码的可以点 这里

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

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

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

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

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

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

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

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

一旦双方达成协商,那么,他们之间便可直接进行通信。此时,即使我们停止 websocket 服务,双方仍然是连通的。因为 websocket 只是作为协商时的通信工具(这个工具可以换成普通的 ajax 请求实现),并不是媒体流的接收和转发服务器。文章中实现了类似远程桌面的功能,其实只要将代码中的navigator.mediaDevices.getDisplayMedia改为navigator.mediaDevices.getUserMedia即可调取摄像头和麦克风等设备,实现视频通话功能。奈何手头没有这些设备,没完全展示了,以后有机会再补充吧。

参考文章

本作品采用《CC 协议》,转载必须注明作者和本文链接
再见了妈妈今晚我就要远航,别为我担心我有快乐和智慧的桨~
本帖由 wj2015 于 2年前 加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 10

本来我打算写个帖子记录一下,看样子省事了😄

2年前 评论
LiamHao (楼主) 2年前

卧槽,666,刚想说玩一玩laravel-echo和websokect就看到这篇文章了,感谢!

2年前 评论

mark火前留名

2年前 评论

請問可以做成group chat 嗎?Mesh topology 的那種。

有點不太懂要怎麽轉,已經卡了兩星期,求指教。

1年前 评论
臭鼬 1年前
RoyPo (作者) 1年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!