为 Laravel 应用添加视频聊天功能
介绍
要求
项目设置
介绍
我受命为一个基于 Vue.js 和 Laravel 的项目开发一个自定义视频聊天应用。为了让它正常运行,我费了不少周折。我会在这里分享我在整个过程中学到的所有经验。
最终项目库:https://github.com/Mupati/laravel-video-chat
要求
-
本教程假设您已了解如何设置
Laravel带有VueJs身份验证的新项目。项目设置完成后,请创建一些用户。您应该熟悉 Laravel 的广播机制,并对 WebSocket 的工作原理有一定的了解。 -
在pusher.com上注册一个免费的 pusher 账号
-
设置您的 ICE 服务器(TURN 服务器)详细信息。本教程是一个很好的指南。如何安装 COTURN。
项目设置
# Install needed packages
composer require pusher/pusher-php-server "~4.0"
npm install --save laravel-echo pusher-js simple-peer
配置后端
- 添加视频页面路由
routes/web.php。这些路由将用于访问视频通话页面、拨打电话和接听电话。
Route::get('/video-chat', function () {
// fetch all users apart from the authenticated user
$users = User::where('id', '<>', Auth::id())->get();
return view('video-chat', ['users' => $users]);
});
// Endpoints to call or receive calls.
Route::post('/video/call-user', 'App\Http\Controllers\VideoChatController@callUser');
Route::post('/video/accept-call', 'App\Http\Controllers\VideoChatController@acceptCall');
- 取消
BroadcastServiceProvider注释config/app.php。这将允许我们使用 Laravel 的广播系统。
+ App\Providers\BroadcastServiceProvider::class
- //App\Providers\BroadcastServiceProvider::class
- 在视频聊天应用程序中创建一个 Presence 频道
routes/channels.php。当已认证用户订阅该频道(presence-video-channel)时,我们会返回用户的 IDid和name。这样我们就能获取已登录并可呼叫的用户。
Broadcast::channel('presence-video-channel', function($user) {
return ['id' => $user->id, 'name' => $user->name];
});
- 创建
StartVideoChat事件。此事件将在拨打电话或接听电话时触发,并在视频通话频道上广播。订阅该频道的用户将在前端监听此事件,以便触发来电通知。
php artisan make:event StartVideoChat
- 将以下代码添加到
app/Events/StartVideoChat.php. StartVideoChat 事件会广播到 ,presence-video-channel以便在频道上共享启动视频通话所需的数据。
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class StartVideoChat implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $data;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($data)
{
$this->data = $data;
}
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PresenceChannel('presence-video-channel');
}
}
- 创建
VideoChatController用于拨打和接听电话的功能。
php artisan make:controller VideoChatController
- 将以下内容添加到
VideoChatController
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
use App\Events\StartVideoChat;
class VideoChatController extends Controller
{
public function callUser(Request $request)
{
$data['userToCall'] = $request->user_to_call;
$data['signalData'] = $request->signal_data;
$data['from'] = Auth::id();
$data['type'] = 'incomingCall';
broadcast(new StartVideoChat($data))->toOthers();
}
public function acceptCall(Request $request)
{
$data['signal'] = $request->signal;
$data['to'] = $request->to;
$data['type'] = 'callAccepted';
broadcast(new StartVideoChat($data))->toOthers();
}
}
视频聊天控制器中的方法
需要理解的一点是,视频聊天应用程序是一个使用 WebSocket 的实时应用程序。这些端点仅用于建立通话双方之间的连接,之后通信数据通过 WebSocket 进行交换。
让我们试着理解一下控制器中的这两个方法的作用:
callUser方法
user_to_call:id呼叫发起者想要联系的用户。signal_data:这是调用方通过 WebRTC 客户端(我们使用的是 simple-peerjs 这个 WebRTC 封装库)发送的初始信号数据(offer)。这些是接收到的参数。我们创建一个data包含两个附加属性的对象,from然后通过前端监听的事件type广播这些数据。StartVideoChatfrom:是id发起呼叫的用户的ID。我们使用已认证用户的ID。type:是数据的一个属性,用于指示通道上有来电。通知将显示给与id该值对应的用户user_to_call。
acceptCall方法
signal这是被叫方的answer数据。to:呼叫者的 IDid。已接听呼叫的信号数据将发送给 ID 匹配的用户to,该 ID 应为呼叫者的 ID。type:添加到要通过通道发送的数据中的属性,表明呼叫接收者已接受呼叫。
配置前端
- 取消注释以下代码块,即可实例化
Laravel Echo并Pusher启用该功能。resources/js/bootstrap.js
+ import Echo from 'laravel-echo';
+ window.Pusher = require('pusher-js');
+ window.Echo = new Echo({
+ broadcaster: 'pusher',
+ key: process.env.MIX_PUSHER_APP_KEY,
+ cluster: process.env.MIX_PUSHER_APP_CLUSTER,
+ forceTLS: true
+ });
- import Echo from 'laravel-echo';
- window.Pusher = require('pusher-js');
- window.Echo = new Echo({
- broadcaster: 'pusher',
- key: process.env.MIX_PUSHER_APP_KEY,
- cluster: process.env.MIX_PUSHER_APP_CLUSTER,
- forceTLS: true
-});
- 创建
resources/js/helpers.js。添加一个getPermissions函数来辅助麦克风和视频的权限访问。此方法处理浏览器进行视频通话所需的音频和视频权限。它会等待用户接受权限后才能继续进行视频通话。我们允许音频和视频同时访问。更多信息请访问MDN 网站。
export const getPermissions = () => {
// Older browsers might not implement mediaDevices at all, so we set an empty object first
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
// Some browsers partially implement mediaDevices. We can't just assign an object
// with getUserMedia as it would overwrite existing properties.
// Here, we will just add the getUserMedia property if it's missing.
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = function(constraints) {
// First get ahold of the legacy getUserMedia, if present
const getUserMedia =
navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
// Some browsers just don't implement it - return a rejected promise with an error
// to keep a consistent interface
if (!getUserMedia) {
return Promise.reject(
new Error("getUserMedia is not implemented in this browser")
);
}
// Otherwise, wrap the call to the old navigator.getUserMedia with a Promise
return new Promise((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject);
});
};
}
navigator.mediaDevices.getUserMedia =
navigator.mediaDevices.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia;
return new Promise((resolve, reject) => {
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then(stream => {
resolve(stream);
})
.catch(err => {
reject(err);
// throw new Error(`Unable to fetch stream ${err}`);
});
});
};
- 创建视频聊天组件
resources/js/components/VideoChat.vue,
<template>
<div>
<div class="container">
<div class="row">
<div class="col">
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-primary mr-2"
v-for="user in allusers"
:key="user.id"
@click="placeVideoCall(user.id, user.name)"
>
Call {{ user.name }}
<span class="badge badge-light">{{
getUserOnlineStatus(user.id)
}}</span>
</button>
</div>
</div>
</div>
<!--Placing Video Call-->
<div class="row mt-5" id="video-row">
<div class="col-12 video-container" v-if="callPlaced">
<video
ref="userVideo"
muted
playsinline
autoplay
class="cursor-pointer"
:class="isFocusMyself === true ? 'user-video' : 'partner-video'"
@click="toggleCameraArea"
/>
<video
ref="partnerVideo"
playsinline
autoplay
class="cursor-pointer"
:class="isFocusMyself === true ? 'partner-video' : 'user-video'"
@click="toggleCameraArea"
v-if="videoCallParams.callAccepted"
/>
<div class="partner-video" v-else>
<div v-if="callPartner" class="column items-center q-pt-xl">
<div class="col q-gutter-y-md text-center">
<p class="q-pt-md">
<strong>{{ callPartner }}</strong>
</p>
<p>calling...</p>
</div>
</div>
</div>
<div class="action-btns">
<button type="button" class="btn btn-info" @click="toggleMuteAudio">
{{ mutedAudio ? "Unmute" : "Mute" }}
</button>
<button
type="button"
class="btn btn-primary mx-4"
@click="toggleMuteVideo"
>
{{ mutedVideo ? "ShowVideo" : "HideVideo" }}
</button>
<button type="button" class="btn btn-danger" @click="endCall">
EndCall
</button>
</div>
</div>
</div>
<!-- End of Placing Video Call -->
<!-- Incoming Call -->
<div class="row" v-if="incomingCallDialog">
<div class="col">
<p>
Incoming Call From <strong>{{ callerDetails.name }}</strong>
</p>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-danger"
data-dismiss="modal"
@click="declineCall"
>
Decline
</button>
<button
type="button"
class="btn btn-success ml-5"
@click="acceptCall"
>
Accept
</button>
</div>
</div>
</div>
<!-- End of Incoming Call -->
</div>
</div>
</template>
<script>
import Peer from "simple-peer";
import { getPermissions } from "../helpers";
export default {
props: [
"allusers",
"authuserid",
"turn_url",
"turn_username",
"turn_credential",
],
data() {
return {
isFocusMyself: true,
callPlaced: false,
callPartner: null,
mutedAudio: false,
mutedVideo: false,
videoCallParams: {
users: [],
stream: null,
receivingCall: false,
caller: null,
callerSignal: null,
callAccepted: false,
channel: null,
peer1: null,
peer2: null,
},
};
},
mounted() {
this.initializeChannel(); // this initializes laravel echo
this.initializeCallListeners(); // subscribes to video presence channel and listens to video events
},
computed: {
incomingCallDialog() {
if (
this.videoCallParams.receivingCall &&
this.videoCallParams.caller !== this.authuserid
) {
return true;
}
return false;
},
callerDetails() {
if (
this.videoCallParams.caller &&
this.videoCallParams.caller !== this.authuserid
) {
const incomingCaller = this.allusers.filter(
(user) => user.id === this.videoCallParams.caller
);
return {
id: this.videoCallParams.caller,
name: `${incomingCaller[0].name}`,
};
}
return null;
},
},
methods: {
initializeChannel() {
this.videoCallParams.channel = window.Echo.join("presence-video-channel");
},
getMediaPermission() {
return getPermissions()
.then((stream) => {
this.videoCallParams.stream = stream;
if (this.$refs.userVideo) {
this.$refs.userVideo.srcObject = stream;
}
})
.catch((error) => {
console.log(error);
});
},
initializeCallListeners() {
this.videoCallParams.channel.here((users) => {
this.videoCallParams.users = users;
});
this.videoCallParams.channel.joining((user) => {
// check user availability
const joiningUserIndex = this.videoCallParams.users.findIndex(
(data) => data.id === user.id
);
if (joiningUserIndex < 0) {
this.videoCallParams.users.push(user);
}
});
this.videoCallParams.channel.leaving((user) => {
const leavingUserIndex = this.videoCallParams.users.findIndex(
(data) => data.id === user.id
);
this.videoCallParams.users.splice(leavingUserIndex, 1);
});
// listen to incomming call
this.videoCallParams.channel.listen("StartVideoChat", ({ data }) => {
if (data.type === "incomingCall") {
// add a new line to the sdp to take care of error
const updatedSignal = {
...data.signalData,
sdp: `${data.signalData.sdp}\n`,
};
this.videoCallParams.receivingCall = true;
this.videoCallParams.caller = data.from;
this.videoCallParams.callerSignal = updatedSignal;
}
});
},
async placeVideoCall(id, name) {
this.callPlaced = true;
this.callPartner = name;
await this.getMediaPermission();
this.videoCallParams.peer1 = new Peer({
initiator: true,
trickle: false,
stream: this.videoCallParams.stream,
config: {
iceServers: [
{
urls: this.turn_url,
username: this.turn_username,
credential: this.turn_credential,
},
],
},
});
this.videoCallParams.peer1.on("signal", (data) => {
// send user call signal
axios
.post("/video/call-user", {
user_to_call: id,
signal_data: data,
from: this.authuserid,
})
.then(() => {})
.catch((error) => {
console.log(error);
});
});
this.videoCallParams.peer1.on("stream", (stream) => {
console.log("call streaming");
if (this.$refs.partnerVideo) {
this.$refs.partnerVideo.srcObject = stream;
}
});
this.videoCallParams.peer1.on("connect", () => {
console.log("peer connected");
});
this.videoCallParams.peer1.on("error", (err) => {
console.log(err);
});
this.videoCallParams.peer1.on("close", () => {
console.log("call closed caller");
});
this.videoCallParams.channel.listen("StartVideoChat", ({ data }) => {
if (data.type === "callAccepted") {
if (data.signal.renegotiate) {
console.log("renegotating");
}
if (data.signal.sdp) {
this.videoCallParams.callAccepted = true;
const updatedSignal = {
...data.signal,
sdp: `${data.signal.sdp}\n`,
};
this.videoCallParams.peer1.signal(updatedSignal);
}
}
});
},
async acceptCall() {
this.callPlaced = true;
this.videoCallParams.callAccepted = true;
await this.getMediaPermission();
this.videoCallParams.peer2 = new Peer({
initiator: false,
trickle: false,
stream: this.videoCallParams.stream,
config: {
iceServers: [
{
urls: this.turn_url,
username: this.turn_username,
credential: this.turn_credential,
},
],
},
});
this.videoCallParams.receivingCall = false;
this.videoCallParams.peer2.on("signal", (data) => {
axios
.post("/video/accept-call", {
signal: data,
to: this.videoCallParams.caller,
})
.then(() => {})
.catch((error) => {
console.log(error);
});
});
this.videoCallParams.peer2.on("stream", (stream) => {
this.videoCallParams.callAccepted = true;
this.$refs.partnerVideo.srcObject = stream;
});
this.videoCallParams.peer2.on("connect", () => {
console.log("peer connected");
this.videoCallParams.callAccepted = true;
});
this.videoCallParams.peer2.on("error", (err) => {
console.log(err);
});
this.videoCallParams.peer2.on("close", () => {
console.log("call closed accepter");
});
this.videoCallParams.peer2.signal(this.videoCallParams.callerSignal);
},
toggleCameraArea() {
if (this.videoCallParams.callAccepted) {
this.isFocusMyself = !this.isFocusMyself;
}
},
getUserOnlineStatus(id) {
const onlineUserIndex = this.videoCallParams.users.findIndex(
(data) => data.id === id
);
if (onlineUserIndex < 0) {
return "Offline";
}
return "Online";
},
declineCall() {
this.videoCallParams.receivingCall = false;
},
toggleMuteAudio() {
if (this.mutedAudio) {
this.$refs.userVideo.srcObject.getAudioTracks()[0].enabled = true;
this.mutedAudio = false;
} else {
this.$refs.userVideo.srcObject.getAudioTracks()[0].enabled = false;
this.mutedAudio = true;
}
},
toggleMuteVideo() {
if (this.mutedVideo) {
this.$refs.userVideo.srcObject.getVideoTracks()[0].enabled = true;
this.mutedVideo = false;
} else {
this.$refs.userVideo.srcObject.getVideoTracks()[0].enabled = false;
this.mutedVideo = true;
}
},
stopStreamedVideo(videoElem) {
const stream = videoElem.srcObject;
const tracks = stream.getTracks();
tracks.forEach((track) => {
track.stop();
});
videoElem.srcObject = null;
},
endCall() {
// if video or audio is muted, enable it so that the stopStreamedVideo method will work
if (!this.mutedVideo) this.toggleMuteVideo();
if (!this.mutedAudio) this.toggleMuteAudio();
this.stopStreamedVideo(this.$refs.userVideo);
if (this.authuserid === this.videoCallParams.caller) {
this.videoCallParams.peer1.destroy();
} else {
this.videoCallParams.peer2.destroy();
}
this.videoCallParams.channel.pusher.channels.channels[
"presence-presence-video-channel"
].disconnect();
setTimeout(() => {
this.callPlaced = false;
}, 3000);
},
},
};
</script>
<style scoped>
#video-row {
width: 700px;
max-width: 90vw;
}
#incoming-call-card {
border: 1px solid #0acf83;
}
.video-container {
width: 700px;
height: 500px;
max-width: 90vw;
max-height: 50vh;
margin: 0 auto;
border: 1px solid #0acf83;
position: relative;
box-shadow: 1px 1px 11px #9e9e9e;
background-color: #fff;
}
.video-container .user-video {
width: 30%;
position: absolute;
left: 10px;
bottom: 10px;
border: 1px solid #fff;
border-radius: 6px;
z-index: 2;
}
.video-container .partner-video {
width: 100%;
height: 100%;
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
z-index: 1;
margin: 0;
padding: 0;
}
.video-container .action-btns {
position: absolute;
bottom: 20px;
left: 50%;
margin-left: -50px;
z-index: 3;
display: flex;
flex-direction: row;
}
/* Mobiel Styles */
@media only screen and (max-width: 768px) {
.video-container {
height: 50vh;
}
}
</style>
视频聊天组件详解。
- 我们
Peer从中导入了simple-peer该软件包,它使我们能够更轻松地与 webrtc 进行交互。 -
该组件接受以下属性::
allusers所有已注册用户(不包括当前已认证用户)。这些用户将显示出来。我们不允许已认证用户调用自身。authuserid:id已认证用户的用户名。 :TURN 服务器的 URL,将在IEturn_url实例中使用。:TURN 服务器的用户名。:turn_username 的密码。simple-peerPeerturn_usernameturn_credential -
组件挂载时,我们
presence-video-channel通过该initializeChannel方法订阅事件。Laravel-echo为此,我们使用了该方法。 -
我们
initializeCallListeners登录了我们订阅的频道。系统提供了一些方法Laravel-echo来统计频道的订阅用户数量、加入和离开频道的用户数量。我们还会监听来电StartVideoChat事件。presence-video-channel -
我们从 prop 中列出数据库中的所有用户
allUsers,并指示他们是否在线。在线意味着他们也订阅了该服务presence-video-channel。这将在您放置组件的任何页面上生效video-chat。在本教程中,我们有一个视频聊天页面,并将该组件放置在该页面上。 -
placeVideoCall用于发起呼叫。我们将被叫用户的用户名和密码作为参数传递。id我们 使用相应的方法请求用户授予浏览器访问麦克风和摄像头的权限。流式数据传输会显示在浏览器中。此时,呼叫者可以在浏览器中看到自己的脸。 我们为呼叫者创建一个对等体(Peer)。当发生信令事件时,我们会将信令数据发送到后端端点。 接收方会收到来电通知,并在接听电话后,通过信令将呼叫者的信息传递给接收方。 监听器会接收到流式数据传输,并在接收方的浏览器中显示。namegetMediaPermissionpeer.on('signal',..)/video/call-useranswerpeer.on('stream',...) -
acceptCall该方法用于接受来电。当用户看到来电通知时,点击“接受”按钮。我们向接收方发送从呼叫方收到的信号数据。
我们获取访问摄像头和麦克风的权限,并将流数据显示在我们的用户界面上。
这会创建一个新的实例,Peer并将initiator属性设置为“是”,false以表明新的对等方是接收方。
我们访问接受呼叫端点,并将我们的信号数据(应答)发送给呼叫方。
当流媒体开始播放时,我们也会在浏览器中显示调用者的流媒体,现在通信无需访问我们的后端,而是通过 pusher 支持的 websocket 继续进行。
- 其余功能用于静音音频、禁用视频流和结束通话。
VideoChat.vue在……中注册组件resources/js/app.js
Vue.component("video-chat", require("./components/VideoChat.vue").default);
- 创建视频聊天视图
resources/views/video-chat.blade.php
@extends('layouts.app')
@section('content')
<video-chat :allusers="{{ $users }}" :authUserId="{{ auth()->id() }}" turn_url="{{ env('TURN_SERVER_URL') }}"
turn_username="{{ env('TURN_SERVER_USERNAME') }}" turn_credential="{{ env('TURN_SERVER_CREDENTIAL') }}" />
@endsection
- 更新环境变量。插入您的 Pusher API 密钥
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=
TURN_SERVER_URL=
TURN_SERVER_USERNAME=
TURN_SERVER_CREDENTIAL=
鸣谢
我发现了很多有用的资源,但我无法在这里一一分享,不过以下这些 YouTube 视频帮助我理解并最终实现了这个方案。
我想听听你对这篇文章的易读性有何看法。
更新
我刚刚发布了一个基于 WebRTC 的实时流媒体实现。点击这里查看:

