在 Django 中用 Agora 创建可伸缩视频聊天APP


Django 中的 Agora 视频聊天应用程序

简介

Django 是一个高级 Python Web 框架,它可以处理 Web 开发中的大部分麻烦,因此你可以只需专注于编写应用程序就可以了。就其本身而言,Agora 消除了从头开始创建视频聊天应用程序的麻烦。

之前,我使用 WebRTC 和 Laravel 创建了一个视频聊天应用程序,点击此处了解相关文章:将视频聊天添加到 Laravel 应用程序。WebRTC 是实现视频聊天功能的一种方式。声网Agora等公司提供了完整的视频聊天 SDK,以提供高质量的实时互动视频聊天体验。作为有 WebRTC 开发经验的人,我可以告诉你 WebRTC 的一些局限性,例如:

  • 体验质量: 由于WebRTC是通过互联网传输的,属于公有领域,体验质量难以保证。
  • 可伸缩性: 由于 WebRTC 的点对点特性,组视频通话的可伸缩 性十分有限。

介绍完Agora 平台后,使我印象深刻的是,使用 Agora SDK 设置相同的视频通话功能比使用 WebRTC 更容易。我将继续使用Agora 和 Laravel创建视频聊天应用程序。然而,在本文中,我不希望将 Django 开发人员被排除在外,因此我们将使用 Django 和 Agora 开发一个视频聊天应用程序。

为什么首选 Agora

在使用 Agora 创建视频聊天应用程序后,我想强调它自身的一些优势:

  • 有一个可以满足所有需求的 SDK ——语音、视频、直播、屏幕共享等等。
  • 我不必在 Amazon EC2 上设置带有coturn的 turn 服务器来中继不同网络上的对等点之间的流量。
  • 每月可以免费获得10,000 分钟,这让你可以随意开发解决方案。
  • 无需管理支持视频通话功能的底层基础设施。
  • 提供直观的API文档

要求

创建项目

  1. 为此项目创建并激活 Python 3 虚拟环境。
  2. 打开终端或命令提示符并导航到你的 Django 项目目录。我们将使用mysite 作为本教程的项目名称。
  3. 创建一个名为agora 的新应用程序。从终端运行以下命令:
python manage.py startapp agora
  1. 从终端或命令提示符安装必要的包:
pip install pusher python-dotenv
  1. 在项目目录 ( mysite ) 中,通过从终端运行以下命令来进行迁移并创建新的超级用户:
python manage.py migrate // run the next command multiple times to create more users python manage.py createsuperuser
  1. 从 Agora 库中下载 AgoraDynamicKey Python 3 代码:AgoraDynamicKey

将下载的文件夹保存在项目文件夹之外的位置。当我们配置后端时,文件夹中的一些文件会复制到我们的项目中。

配置后端

我们将用生成 Agora 令牌建立调用所需的方法创建视图和类。我们还将在服务器端设置 Pusher。

1.在mysite/settings.py中将agora添加到已安装的应用程序中

// At the top of the file
import dotenv
dotenv.load_dotenv()


// in the INSTALLED_APPS list
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Your agora app comes here
    'agora'
]

2. 添加应用路由

agora 目录下创建一个名为urls.py 的文件,从终端或命令提示符中添加以下代码:

touch agora/urls.py

将以下内容添加到agora/urls.py

from django.urls import path
from . import views
urlpatterns = [
    path(' ', views.index, name='agora-index'),
    path('pusher/auth/', views.pusher_auth, name='agora-pusher-auth'),
    path('token/', views.generate_agora_token, name='agora-token'),
    path('call-user/', views.call_user, name='agora-call-user'),
]

在项目级别上注册 Agora 应用路由。添加以下代码到mysite/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    # Agora Route
    path('', include('agora.urls')),
    path('admin/', admin.site.urls),
]

3. 添加下载号的 AgoraDynamicKey 生成器文件

  1. 打开命令提示符,在agora 目录中,创建一个名为agora_key 的子目录:
cd agora
mkdir agora_key

2.从下载文件的SRC 目录中复制AccessToken.pyRtcTokenBuilder.py ,并将它们添加到agora_key 目录:

4. 在 agora/views.py 中为 Agora 应用创建视图

agora/views.py 文件中添加以下代码块

import os
import time
import json

from django.http.response import JsonResponse
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required

from django.shortcuts import render

from .agora_key.RtcTokenBuilder import RtcTokenBuilder, Role_Attendee
from pusher import Pusher


# Instantiate a Pusher Client
pusher_client = Pusher(app_id=os.environ.get('PUSHER_APP_ID'),
                       key=os.environ.get('PUSHER_KEY'),
                       secret=os.environ.get('PUSHER_SECRET'),
                       ssl=True,
                       cluster=os.environ.get('PUSHER_CLUSTER')
                       )


@login_required(login_url='/admin/')
def index(request):
    User = get_user_model()
    all_users = User.objects.exclude(id=request.user.id).only('id', 'username')
    return render(request, 'agora/index.html', {'allUsers': all_users})


def pusher_auth(request):
    payload = pusher_client.authenticate(
        channel=request.POST['channel_name'],
        socket_id=request.POST['socket_id'],
        custom_data={
            'user_id': request.user.id,
            'user_info': {
                'id': request.user.id,
                'name': request.user.username
            }
        })
    return JsonResponse(payload)


def generate_agora_token(request):
    appID = os.environ.get('AGORA_APP_ID')
    appCertificate = os.environ.get('AGORA_APP_CERTIFICATE')
    channelName = json.loads(request.body.decode(
        'utf-8'))['channelName']
    userAccount = request.user.username
    expireTimeInSeconds = 3600
    currentTimestamp = int(time.time())
    privilegeExpiredTs = currentTimestamp + expireTimeInSeconds

    token = RtcTokenBuilder.buildTokenWithAccount(
        appID, appCertificate, channelName, userAccount, Role_Attendee, privilegeExpiredTs)

    return JsonResponse({'token': token, 'appID': appID})


def call_user(request):
    body = json.loads(request.body.decode('utf-8'))

    user_to_call = body['user_to_call']
    channel_name = body['channel_name']
    caller = request.user.id

    pusher_client.trigger(
        'presence-online-channel',
        'make-agora-call',
        {
            'userToCall': user_to_call,
            'channelName': channel_name,
            'from': caller
        }
    )
    return JsonResponse({'message': 'call has been placed'})

agora/views.py 中的功能分解

  • index : 查看视频通话页面。只有经过身份验证的用户才能查看该页面。未经身份验证的用户被重新导航到登录页面。除了要在前端呈现的当前经过身份验证的用户之外,我们返回所有用户的列表。
  • pusher_auth :当登录用户加入 Pusher 在线频道时,它充当对登录用户进行身份验证的端点。推送器认证成功后返回用户ID和用户名。
  • generate_agora_token : 生成 Agora 动态令牌。该令牌用于在应用用户加入 Agora 频道建立通话时对其进行身份验证。
  • call_user :这会在所有登录用户订阅的当前在线频道 上触发一个make-agora-call 事件。

当前在线频道make-agora-call 事件直播的数据包含以下内容:

  • userToCall :这是应该接收呼叫的用户 ID。
  • channelName :这是呼叫者在前端已经加入的呼叫通道。这是客户端使用 Agora SDK 创建的频道。是呼叫者已经加入的房间,等待被接听者也加入并建立呼叫连接。
  • from : 呼叫者 ID。

make-agora-call 事件中,如果 userToCall 值与他们的 ID 匹配,用户可以确定他们是否正在被调用。我们设置一个带有按钮的来电通知来接听电话。他们通过 from 的值知道调用者的身份。

配置前端

我们将创建用于拨打和接听视频通话的用户界面,并能够切换摄像头和麦克风的开启和关闭状态。

1. 为索引视图创建 HTML 文件。

该 HTML 文件将包含指向 Agora SDK、Vue.js、Pusher、Bootstrap 样式以及我们自定义的 CSS 和 JavaScript 的 CDN 的链接。

在你的终端中,导航到agora 目录并在其中创建一个模板 目录和一个agora 子目录。

agora 子目录中创建index.html 文件:

cd agora
mkdir -p templates/agora
touch templates/agora/index.html

将以下内容添加到index.html 文件中。

{% load static %}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Build A Scalable Video Chat Application With Agora"
    />
    <meta
      name="keywords"
      content="Video Call, Agora, Django, Real Time Engagement"
    />
    <meta name="author" content="Kofi Obrasi Ocran" />
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css"
      integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l"
      crossorigin="anonymous"
    />
    <link
      rel="stylesheet"
      type="text/css"
      href="{% static 'agora/index.css' %}"
    />
    <script src="https://cdn.agora.io/sdk/release/AgoraRTCSDK-3.3.1.js"></script>
    <title>Agora Video Chat Django</title>
  </head>
  <body>
    <main id="app">
      <main>
        <div class="container">
          <div class="row">
            <div class="col-12 text-center">
              <img
                src="{% static 'agora/agora-logo.png' %}"
                alt="Agora Logo"
                class="block img-fuild"
              />
            </div>
          </div>
        </div>
        <div class="container my-5">
          <div class="row">
            <div class="col">
              <div class="btn-group" role="group">
                {% for singleUser in allUsers%}
                <button
                  type="button"
                  class="btn btn-primary mr-2"

                  @click="placeCall('{{singleUser.id}}','{{singleUser}}')"
                >
                  Call {{ singleUser }}
                  <span class="badge badge-light"
                    >${ getUserOnlineStatus({{singleUser.id}})}</span
                  >
                </button>

                {% endfor %}
              </div>
            </div>
          </div>

          <!-- Incoming Call  -->
          <div class="row my-5" v-if="incomingCall">
            <div class="col-12">
              <p>Incoming Call From <strong>${ incomingCaller }</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>

        <section id="video-container" v-if="callPlaced">
          <div id="local-video"></div>
          <div id="remote-video"></div>

          <div class="action-btns">
            <button
              type="button"
              class="btn btn-info"
              @click="handleAudioToggle"
            >
              ${ mutedAudio ? "Unmute" : "Mute" }
            </button>
            <button
              type="button"
              class="btn btn-primary mx-4"
              @click="handleVideoToggle"
            >
              ${ mutedVideo ? "ShowVideo" : "HideVideo" }
            </button>
            <button type="button" class="btn btn-danger" @click="endCall">
              EndCall
            </button>
          </div>

        </section>
      </main>
    </main>
    <!-- Add Scripts -->

    <script src="https://cdnjs.cloudflare.com/ajax/libs/pusher/7.0.3/pusher.min.js"></script>
    <script>
      window.pusher = new Pusher("420e941c25574fda6378", {
        authEndpoint: "{% url 'agora-pusher-auth' %}",
        auth: {
          headers: {
            "X-CSRFToken": "{{ csrf_token }}",
          },
        },
      });

      const AUTH_USER = "{{user}}"
      const AUTH_USER_ID =  "{{request.user.id}}"
      const CSRF_TOKEN = "{{ csrf_token }}"
    </script>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js""
    ></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="{% static 'agora/index.js' %}"></script>
  </body>
</html>

2. 创建静态文件

我们有自定义风格的index.cssindex.js ,这是我们处理呼叫逻辑的脚本。

将以下内容添加到index.js

const app = new Vue({
  el: "#app",
  delimiters: ["${", "}"],
  data: {
    callPlaced: false,
    client: null,
    localStream: null,
    mutedAudio: false,
    mutedVideo: false,
    userOnlineChannel: null,
    onlineUsers: [],
    incomingCall: false,
    incomingCaller: "",
    agoraChannel: null,
  },
  mounted() {
    this.initUserOnlineChannel();
  },

  methods: {
    initUserOnlineChannel() {
      const userOnlineChannel = pusher.subscribe("presence-online-channel");

      // Start Pusher Presence Channel Event Listeners

      userOnlineChannel.bind("pusher:subscription_succeeded", (data) => {
        // From Laravel Echo, wrapper for Pusher Js Client
        let members = Object.keys(data.members).map((k) => data.members[k]);
        this.onlineUsers = members;
      });

      userOnlineChannel.bind("pusher:member_added", (data) => {
        let user = data.info;
        // check user availability
        const joiningUserIndex = this.onlineUsers.findIndex(
          (data) => data.id === user.id
        );
        if (joiningUserIndex < 0) {
          this.onlineUsers.push(user);
        }
      });

      userOnlineChannel.bind("pusher:member_removed", (data) => {
        let user = data.info;
        const leavingUserIndex = this.onlineUsers.findIndex(
          (data) => data.id === user.id
        );
        this.onlineUsers.splice(leavingUserIndex, 1);
      });

      userOnlineChannel.bind("pusher:subscription_error", (err) => {
        console.log("Subscription Error", err);
      });

      userOnlineChannel.bind("an_event", (data) => {
        console.log("a_channel: ", data);
      });

      userOnlineChannel.bind("make-agora-call", (data) => {
        // Listen to incoming call. This can be replaced with a private channel

        if (parseInt(data.userToCall) === parseInt(AUTH_USER_ID)) {
          const callerIndex = this.onlineUsers.findIndex(
            (user) => user.id === data.from
          );
          this.incomingCaller = this.onlineUsers[callerIndex]["name"];
          this.incomingCall = true;

          // the channel that was sent over to the user being called is what
          // the receiver will use to join the call when accepting the call.
          this.agoraChannel = data.channelName;
        }
      });
    },

    getUserOnlineStatus(id) {
      const onlineUserIndex = this.onlineUsers.findIndex(
        (data) => data.id === id
      );
      if (onlineUserIndex < 0) {
        return "Offline";
      }
      return "Online";
    },

    async placeCall(id, calleeName) {
      try {
        // channelName = the caller's and the callee's id. you can use anything. tho.
        const channelName = `${AUTH_USER}_${calleeName}`;
        const tokenRes = await this.generateToken(channelName);

        // // Broadcasts a call event to the callee and also gets back the token
        let placeCallRes = await axios.post(
          "/call-user/",
          {
            user_to_call: id,
            channel_name: channelName,
          },
          {
            headers: {
              "Content-Type": "application/json",
              "X-CSRFToken": CSRF_TOKEN,
            },
          }
        );

        this.initializeAgora(tokenRes.data.appID);
        this.joinRoom(tokenRes.data.token, channelName);
      } catch (error) {
        console.log(error);
      }
    },

    async acceptCall() {
      const tokenRes = await this.generateToken(this.agoraChannel);
      this.initializeAgora(tokenRes.data.appID);

      this.joinRoom(tokenRes.data.token, this.agoraChannel);
      this.incomingCall = false;
      this.callPlaced = true;
    },

    declineCall() {
      // You can send a request to the caller to
      // alert them of rejected call
      this.incomingCall = false;
    },

    generateToken(channelName) {
      return axios.post(
        "/token/",
        {
          channelName,
        },
        {
          headers: {
            "Content-Type": "application/json",
            "X-CSRFToken": CSRF_TOKEN,
          },
        }
      );
    },

    /**
     * Agora Events and Listeners
     */
    initializeAgora(agora_app_id) {
      this.client = AgoraRTC.createClient({ mode: "rtc", codec: "h264" });
      this.client.init(
        agora_app_id,
        () => {
          console.log("AgoraRTC client initialized");
        },
        (err) => {
          console.log("AgoraRTC client init failed", err);
        }
      );
    },

    async joinRoom(token, channel) {
      this.client.join(
        token,
        channel,
        AUTH_USER,
        (uid) => {
          console.log("User " + uid + " join channel successfully");
          this.callPlaced = true;
          this.createLocalStream();
          this.initializedAgoraListeners();
        },
        (err) => {
          console.log("Join channel failed", err);
        }
      );
    },

    initializedAgoraListeners() {
      //   Register event listeners
      this.client.on("stream-published", function (evt) {
        console.log("Publish local stream successfully");
        console.log(evt);
      });

      //subscribe remote stream
      this.client.on("stream-added", ({ stream }) => {
        console.log("New stream added: " + stream.getId());
        this.client.subscribe(stream, function (err) {
          console.log("Subscribe stream failed", err);
        });
      });

      this.client.on("stream-subscribed", (evt) => {
        // Attach remote stream to the remote-video div

        console.log("incoming remote stream event: ", evt);

        evt.stream.play("remote-video");
        this.client.publish(evt.stream);
      });

      this.client.on("stream-removed", ({ stream }) => {
        console.log(String(stream.getId()));
        stream.close();
      });

      this.client.on("peer-online", (evt) => {
        console.log("peer-online", evt.uid);
      });

      this.client.on("peer-leave", (evt) => {
        var uid = evt.uid;
        var reason = evt.reason;
        console.log("remote user left ", uid, "reason: ", reason);
      });

      this.client.on("stream-unpublished", (evt) => {
        console.log(evt);
      });
    },

    createLocalStream() {
      this.localStream = AgoraRTC.createStream({
        audio: true,
        video: true,
      });

      // Initialize the local stream
      this.localStream.init(
        () => {
          // Play the local stream
          this.localStream.play("local-video");
          // Publish the local stream
          this.client.publish(this.localStream, (err) => {
            console.log("publish local stream", err);
          });
        },
        (err) => {
          console.log(err);
        }
      );
    },

    endCall() {
      this.localStream.close();
      this.client.leave(
        () => {
          console.log("Leave channel successfully");
          this.callPlaced = false;
        },
        (err) => {
          console.log("Leave channel failed");
        }
      );
      window.pusher.unsubscribe();
    },

    handleAudioToggle() {
      if (this.mutedAudio) {
        this.localStream.unmuteAudio();
        this.mutedAudio = false;
      } else {
        this.localStream.muteAudio();
        this.mutedAudio = true;
      }
    },

    handleVideoToggle() {
      if (this.mutedVideo) {
        this.localStream.unmuteVideo();
        this.mutedVideo = false;
      } else {
        this.localStream.muteVideo();
        this.mutedVideo = true;
      }
    },
  },
});

将以下内容添加到index.css

main {
  margin-top: 50px;
}

#video-container {
  width: 700px;
  height: 500px;
  max-width: 90vw;
  max-height: 50vh;
  margin: 0 auto;
  border: 1px solid #099dfd;
  position: relative;
  box-shadow: 1px 1px 11px #9e9e9e;
  background-color: #fff;
}

#local-video {
  width: 30%;
  height: 30%;
  position: absolute;
  left: 10px;
  bottom: 10px;
  border: 1px solid #fff;
  border-radius: 6px;
  z-index: 2;
  cursor: pointer;
}

#remote-video {
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
  z-index: 1;
  margin: 0;
  padding: 0;
  cursor: pointer;
}

.action-btns {
  position: absolute;
  bottom: 20px;
  left: 50%;
  margin-left: -50px;
  z-index: 3;
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
}

Agora 通话页面分解

在视频通话页面(index.html )上,我们显示了带有每个注册用户姓名的按钮,以及他们目前的在线状态。

要想拨打电话,我们单击具有在线状态用户的按钮。在线用户是可以接听电话的用户。在演示过程中,我们会​​看到一个用户列表,名为Bar 的用户显示为在线,名为Foo 的调用者可以通过单击按钮来调用Bar

显示所有用户名称及其在线状态的按钮

Bar 收到显示接受和拒绝的按钮以及来电者姓名的来电通知:

1__lMG4lzRZrvuJGFlfdsdIw

来电通知

从上面的来电通知图片中,我们看到来电者的名字是Foo。 然后Bar 可以接受呼叫以建立连接。

下图从代码的角度解释了调用逻辑:

3.使用 Pusher 和 Agora 密钥更新 env 变量。

该**.ENV** 文件位于项目文件夹的根目录。添加你从 Agora 和 Pusher 获得的凭据:

PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=
PUSHER_CLUSTER=
AGORA_APP_ID=
AGORA_APP_CERTIFICATE=

测试

  1. 从终端启动 Django 开发服务器:
python manage.py runserver
  1. 打开两个不同的浏览器或同一浏览器的两个实例,其中一个实例处于隐身模式,然后转到http://127.0.0.1:8000

  2. 如果你还没登录,你将看到 Django 管理员登录页面。

  3. 成功登录后,你将进入 Django 管理仪表板。单击右上角的查看站点链接以导航到视频通话页面。

  4. 在你打开的每个浏览器中,都会显示在应用程序上注册的其他用户。

  5. 在同一个浏览器中,你可以通过单击带有在线用户姓名的按钮来呼叫在线用户。

  6. 提示另一个用户单击接受按钮以建立呼叫。

视频通话的视频演示

要确认你的操作是否正常运行,可以参阅我的演示视频作为完成项目外观和功能的示例:Agora + Django Video Call Demo
演示视频链接 Agora Django - YouTube

结论

你现在已经在 Django 应用程序中实现了视频通话功能!没那么难,对吧?

要在你的 Web 应用程序中包含视频通话功能,大可不必从头开始创建。

Agora 提供了许多开箱即用的强大功能。它还可以帮助企业在现有项目中实施视频聊天时节省开发时间。开发人员唯一要做的就是创建一个引人注目的前端——Agora 处理视频聊天后端。

项目库的链接:https://github.com/Mupati/agora-django-video-call

在线演示链接:https://fleet-server.herokuapp.com/agora/login/?next=/agora/dashboard/

确保演示链接或生产版本由 HTTPS 提供。

测试账号:

foo: DY6m7feJtbnx3ud

bar: Me3tm5reQpWcn3Q

原文作者 Kofi Obrasi Ocran
原文链接 https://medium.com/@mupati/build-a-scalable-video-chat-app-with-agora-in-django-99dbb8a090f

推荐阅读
相关专栏
SDK 教程
164 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。