「手把手」实现网页视频聊天功能
在前面的两篇文章里,我们通过 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 协议》,转载必须注明作者和本文链接
推荐文章: