发布于 2026-01-06 0 阅读
0

在 Laravel 应用中添加视频聊天功能简介 要求 项目设置

为 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
Enter fullscreen mode Exit fullscreen mode

配置后端

  • 添加视频页面路由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');
Enter fullscreen mode Exit fullscreen mode
  • 取消BroadcastServiceProvider注释config/app.php。这将允许我们使用 Laravel 的广播系统。
+ App\Providers\BroadcastServiceProvider::class
- //App\Providers\BroadcastServiceProvider::class 
Enter fullscreen mode Exit fullscreen mode
  • 在视频聊天应用程序中创建一个 Presence 频道routes/channels.php。当已认证用户订阅该频道(presence-video-channel)时,我们会返回用户的 IDidname。这样我们就能获取已登录并可呼叫的用户。
Broadcast::channel('presence-video-channel', function($user) {
    return ['id' => $user->id, 'name' => $user->name];
});
Enter fullscreen mode Exit fullscreen mode
  • 创建StartVideoChat事件。此事件将在拨打电话或接听电话时触发,并在视频通话频道上广播。订阅该频道的用户将在前端监听此事件,以便触发来电通知。
php artisan make:event StartVideoChat
Enter fullscreen mode Exit fullscreen mode
  • 将以下代码添加到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');
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 创建VideoChatController用于拨打和接听电话的功能。
php artisan make:controller VideoChatController
Enter fullscreen mode Exit fullscreen mode
  • 将以下内容添加到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();
    }
}
Enter fullscreen mode Exit fullscreen mode

视频聊天控制器中的方法

需要理解的一点是,视频聊天应用程序是一个使用 WebSocket 的实时应用程序。这些端点仅用于建立通话双方之间的连接,之后通信数据通过 WebSocket 进行交换。

让我们试着理解一下控制器中的这两个方法的作用:

callUser方法

  • user_to_callid呼叫发起者想要联系的用户。
  • signal_data:这是调用方通过 WebRTC 客户端(我们使用的是 simple-peerjs 这个 WebRTC 封装库)发送的初始信号数据(offer)。这些是接收到的参数。我们创建一个data包含两个附加属性的对象,from然后通过前端监听的事件type广播这些数据。StartVideoChat
  • from:是id发起呼叫的用户的ID。我们使用已认证用户的ID。
  • type:是数据的一个属性,用于指示通道上有来电。通知将显示给与id该值对应的用户user_to_call

acceptCall方法

  • signal这是被叫方的answer数据。
  • to:呼叫者的 ID id。已接听呼叫的信号数据将发送给 ID 匹配的用户to,该 ID 应为呼叫者的 ID。
  • type:添加到要通过通道发送的数据中的属性,表明呼叫接收者已接受呼叫。

配置前端

  • 取消注释以下代码块,即可实例化Laravel EchoPusher启用该功能。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
-});
Enter fullscreen mode Exit fullscreen mode
  • 创建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}`);
            });
    });
};
Enter fullscreen mode Exit fullscreen mode

来电

  • 创建视频聊天组件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>
Enter fullscreen mode Exit fullscreen mode

视频聊天组件详解。

  • 我们Peer从中导入了simple-peer该软件包,它使我们能够更轻松地与 webrtc 进行交互。
  • 该组件接受以下属性::

    allusers所有已注册用户(不包括当前已认证用户)。这些用户将显示出来。我们不允许已认证用户调用自身。


    authuseridid已认证用户的用户名。 :TURN 服务器的 URL,将在IE

    turn_url实例中使用:TURN 服务器的用户名。:turn_username 的密码。simple-peerPeer


    turn_username

    turn_credential

  • 组件挂载时,我们presence-video-channel通过该initializeChannel方法订阅事件。Laravel-echo为此,我们使用了该方法。

  • 我们initializeCallListeners登录了我们订阅的频道。系统提供了一些方法Laravel-echo来统计频道的订阅用户数量、加入和离开频道的用户数量。我们还会监听来电StartVideoChat事件。presence-video-channel

  • 我们从 prop 中列出数据库中的所有用户allUsers,并指示他们是否在线。在线意味着他们也订阅了该服务presence-video-channel。这将在您放置组件的任何页面上生效video-chat。在本教程中,我们有一个视频聊天页面,并将该组件放置在该页面上。

  • placeVideoCall用于发起呼叫。我们将被叫用户的用户名和密码作为参数传递。id我们 使用相应的方法请求用户授予浏览器访问麦克风和摄像头的权限。流式数据传输会显示在浏览器中。此时,呼叫者可以在浏览器中看到自己的脸。 我们为呼叫者创建一个对等体(Peer)。当发生信令事件时,我们会将信令数据发送到后端端点。 接收方会收到来电通知,并在接听电话后,通过信令将呼叫者的信息传递给接收方 监听器会接收到流式数据传输,并在接收方的浏览器中显示。name
    getMediaPermission
    peer.on('signal',..)/video/call-user
    answer

    peer.on('stream',...)

  • acceptCall该方法用于接受来电。当用户看到来电通知时,点击“接受”按钮。我们向接收方发送从呼叫方收到的信号数据。

    我们获取访问摄像头和麦克风的权限,并将流数据显示在我们的用户界面上。

    这会创建一个新的实例,Peer并将initiator属性设置为“是”,false以表明新的对等方是接收方。

    我们访问接受呼叫端点,并将我们的信号数据(应答)发送给呼叫方。

当流媒体开始播放时,我们也会在浏览器中显示调用者的流媒体,现在通信无需访问我们的后端,而是通过 pusher 支持的 websocket 继续进行。

  • 其余功能用于静音音频、禁用视频流和结束通话。

视频聊天

  • VideoChat.vue在……中注册组件resources/js/app.js
Vue.component("video-chat", require("./components/VideoChat.vue").default);
Enter fullscreen mode Exit fullscreen mode
  • 创建视频聊天视图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
Enter fullscreen mode Exit fullscreen mode
  • 更新环境变量。插入您的 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=
Enter fullscreen mode Exit fullscreen mode

鸣谢

我发现了很多有用的资源,但我无法在这里一一分享,不过以下这些 YouTube 视频帮助我理解并最终实现了这个方案。

  1. 与 Chaim 一起编程
  2. 我们编写代码

我想听听你对这篇文章的易读性有何看法。

更新

我刚刚发布了一个基于 WebRTC 的实时流媒体实现。点击这里查看:

文章来源:https://dev.to/mupati/adding-video-chat-to-your-laravel-app-5ak7