在 Vue JS 和 Flask 框架下用声网Agora RTM 和 RTC 进行视频通话邀请

如果你想在网页应用程序中加入视频通话功能,就一定不要错过这篇文章。

本文我们将使用声网Agora Web SDK 搭建一个网页应用,且让应用具有与 WhatsApp 网页版类似的视频通话功能。

本文搭建的功能

  • 用来电通知邀请用户加入视频通话

  • 如果用户不在线,就返回相应信息

  • 在电话接通前终止通话

  • 拒绝/挂断通话

  • 接受/接听通话

前期准备

项目设置

1.为项目创建并激活一个 Python 3 虚拟环境。

2.打开你的终端或命令提示符,找到前期准备时下载的 starter 项目,该文件夹的名称为 agora-flask-starter

3.根据 agora-flask-starter 中的 README.md 文件的指示设置应用程序。

4.安装 agora_token_builder 软件包:pip install agora-token-builder

5.下载最新版本的 Agora RTM SDK:Agora RTM SDK

配置后端

我们将创建一个名为 agora_rtm 的新应用,注册其蓝图,并创建所需的静态模板和视图。

1.创建应用程序所需的文件夹:

mkdir app/agora_rtm
mkdir app/static/agora_rtm
mkdir app/templates/agora_rtm

2.创建声网Agora 视图:

  • 在终端的 app/agora_rtm 目录下创建 views.py 和 init.py
touch app/agora_rtm/views.py
touch app/agora_rtm/__init__.py
  • init.py 中加入以下代码:
from flask import Blueprint
agora_rtm = Blueprint('agora_rtm', '__init__')

from . import views  # isort:skip
  • views.py 中添加以下代码:
import os
import time
from flask import render_template, jsonify, request
from flask_login import login_required, current_user

from agora_token_builder import RtcTokenBuilder, RtmTokenBuilder

from . import agora_rtm
from ..models import User


ROLE_RTM_USER = 1
ROLE_PUBLISHER = 1


@agora_rtm.route('/agora-rtm')
@login_required
def index():
    users = User.query.all()
    all_users = [user.to_json() for user in users]
    return render_template('agora_rtm/index.html', title='Agora Video Call with RTM', allUsers=all_users, agoraAppID=os.environ.get('AGORA_APP_ID'))


@agora_rtm.route('/users')
def fetch_users():
    users = User.query.all()
    all_users = [user.to_json() for user in users]
    return jsonify(all_users)


@agora_rtm.route('/agora-rtm/token',  methods=['POST'])
def generate_agora_token():
    auth_user = current_user.to_json()
    appID = os.environ.get('AGORA_APP_ID')
    appCertificate = os.environ.get('AGORA_APP_CERTIFICATE')
    channelName = request.json['channelName']
    userAccount = auth_user['username']
    expireTimeInSeconds = 3600
    currentTimestamp = int(time.time())
    privilegeExpiredTs = currentTimestamp + expireTimeInSeconds

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

    rtm_token = RtmTokenBuilder.buildToken(
        appID, appCertificate, userAccount, ROLE_RTM_USER, privilegeExpiredTs)
    return jsonify({'token': token, 'rtm_token': rtm_token, 'appID': appID})

3.注册 agora_rtm 蓝图。

导入 agora_rtm 应用程序,并在 app/init.py 中将其注册为蓝图:

from .agora_rtm import agora_rtm as agora_rtm_blueprint
app.register_blueprint(agora_rtm_blueprint)

把上述代码放在返回应用程序 语句之前。

agora_rtm/views.py 中的方法分解

索引 :查看视频通话页面。只有经过认证的用户才能查看该页面,未认证的用户会被重跳转到登录页面。我们会返回一个包含所有注册用户的列表。

fetch_users: 将所有注册用户作为 JSON 响应返回。

generate_agora_token: 返回用于 RTM 和 RTC 连接的令牌。

  • token:RTC 令牌

  • rtm_token:RTM 令牌

注意,我们将令牌的过期时间设置为 3600s(1 小时),大家可以自行修改端点来使用想要的过期时间。

配置前端

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

我们显示来电通知,接收方可以接受或拒绝该电话。

1.将下载的 RTM SDK 添加到项目中。

  • 解压在第 5 步 “项目设置”中下载的文件。

  • 找到 libs 文件夹,将 agora-rtm-sdk-1.4.4.js 文件复制到 static/agora_rtm

  • 将复制的文件重命名为 agora-rtm.js

2.添加索引视图的 HTML 文件。

该 HTML 文件将包含 Agora RTC SDK、Agora RTM SDK、 Vue.js、Axios、用于样式的 Bootstrap 以及我们的自定义 CSS 和 JavaScript 的 CDN 链接。

index.html 文件也将继承一个基础模板,用于渲染视图。

  • templates/agora_rtm 创建一个 index.html 文件:touch app/templates/agora_rtm/index.html

  • index.html 文件中添加以下代码:

{% extends "base.html" %} {% block head_scripts %}
<link
  rel="stylesheet"
  type="text/css"
  href="{{ url_for('static', filename='agora/index.css') }}"
/>
<script src="https://download.agora.io/sdk/release/AgoraRTC_N-4.7.0.js"></script>
<script src="{{ url_for('static', filename='agora_rtm/agora-rtm.js') }}"></script>
{% endblock head_scripts %} {% block content%}
<div id="app">
  <div class="container my-5">
    <div class="row">
      <div class="col" v-if="isLoggedIn">
        <div class="btn-group" role="group" id="btnGroup">
          {% for singleUser in allUsers%} {% if singleUser['id'] !=
          current_user['id'] %} {% set username = singleUser['username']%}
          <button
            type="button"
            class="btn btn-primary mr-2 my-2"
            @click="placeCall('{{username}}')"
          >
            Call {{ username}}
            <span class="badge badge-light"
              >${updatedOnlineStatus?.["{{username}}"]?.toLowerCase() ||
              'offline'}</span
            >
          </button>
          {% endif %} {% endfor %}
        </div>
      </div>
    </div>

    <div class="row my-5" v-if="isCallingUser">
      <div class="col-12">
        <p>${callingUserNotification}</p>
        <button type="button" class="btn btn-danger" @click="cancelCall">
          Cancel Call
        </button>
      </div>
    </div>

    <!-- Incoming Call  -->
    <div class="row my-5" v-if="incomingCall">
      <div class="col-12">
        <!-- <p>Incoming Call From <strong>${ incomingCaller }</strong></p> -->
        <p>${incomingCallNotification}</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" ref="localVideo"></div>
    <div id="remote-video" ref="remoteVideo"></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>
</div>
{% endblock content %}

<!-- Add Scripts -->
{% block bottom_scripts%}
<script>
  const AUTH_USER = "{{current_user['username']}}";
  const AUTH_USER_ID = "{{current_user['id']}}";
  const CSRF_TOKEN = "{{ csrf_token }}";
  const AGORA_APP_ID = "{{agoraAppID}}";
</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="{{ url_for('static', filename='agora_rtm/index.js') }}"></script>
{% endblock bottom_scripts %}

我们使用 Flask 的模板语言来重复使用一些代码。如上所述, 我们继承了一个名为 base.html 的基础模板,它有以下几个块:

  • head_scripts: 这是我们放置 Agora RTC 和 RTM SDK 的链接以及用于设计视频通话页面的 index.css 的块。

  • content: 内容块包含渲染视频流的用户界面及其控制按钮。

  • bottom_scripts: 这个区块包含用于发送 AJAX 请求的到 Axios 的 CDN 链接,为我们的视频聊天应用程序编写客户端逻辑的 Vue.js。我们也有 index.js,用于我们的自定义 JavaScript 代码。

3.创建静态文件

我们用 index.css 自定义样式,用 index.js 作为处理调用逻辑的脚本。

从终端或命令提示符运行以下命令来创建这些文件:

touch app/static/agora_rtm/index.js
touch app/static/agora_rtm/index.css

index.js 中加入以下代码:

const app = new Vue({
  el: "#app",
  delimiters: ["${", "}"],
  data: {
    callPlaced: false,
    localStream: null,
    mutedAudio: false,
    mutedVideo: false,
    onlineUsers: [],
    isLoggedIn: false,
    incomingCall: false,
    incomingCaller: "",
    incomingCallNotification: "",
    rtmClient: null,
    rtmChannelInstance: null,
    rtcClient: null,
    users: [],
    updatedOnlineStatus: {},
    rtmChannelName: null,
    isCallingUser: false,
    callingUserNotification: "",
    localAudioTrack: null,
    localVideoTrack: null,
    remoteVideoTrack: null,
    remoteAudioTrack: null,
  },
  mounted() {
    this.fetchUsers();
    this.initRtmInstance();
  },

  created() {
    window.addEventListener("beforeunload", this.logoutUser);
  },

  beforeDestroy() {
    this.endCall();
    this.logoutUser();
  },

  methods: {
    async fetchUsers() {
      const { data } = await axios.get("/users");
      this.users = data;
    },

    async logoutUser() {
      console.log("destroyed!!!");
      this.rtmChannelInstance.leave(AUTH_USER);
      await this.rtmClient.logout();
    },

    async initRtmInstance() {
      // initialize an Agora RTM instance
      this.rtmClient = AgoraRTM.createInstance(AGORA_APP_ID, {
        enableLogUpload: false,
      });

      // RTM Channel to be used
      this.rtmChannelName = "videoCallChannel";

      // Generate the RTM token
      const { data } = await this.generateToken(this.rtmChannelName);

      // Login when it mounts
      await this.rtmClient.login({
        uid: AUTH_USER,
        token: data.rtm_token,
      });

      this.isLoggedIn = true;

      // RTM Message Listeners
      this.rtmClient.on("MessageFromPeer", (message, peerId) => {
        console.log("MessageFromPeer");
        console.log("message: ", message);
        console.log("peerId: ", peerId);
      });

      // Display connection state changes
      this.rtmClient.on("ConnectionStateChanged", (state, reason) => {
        console.log("ConnectionStateChanged");
        console.log("state: ", state);
        console.log("reason: ", reason);
      });
      // Emitted when a Call Invitation is sent from Remote User
      this.rtmClient.on("RemoteInvitationReceived", (data) => {
        this.remoteInvitation = data;
        this.incomingCall = true;
        this.incomingCaller = data.callerId;
        this.incomingCallNotification = `Incoming Call From ${data.callerId}`;

        data.on("RemoteInvitationCanceled", () => {
          console.log("RemoteInvitationCanceled: ");
          this.incomingCallNotification = "Call has been cancelled";
          setTimeout(() => {
            this.incomingCall = false;
          }, 5000);
        });
        data.on("RemoteInvitationAccepted", (data) => {
          console.log("REMOTE INVITATION ACCEPTED: ", data);
        });
        data.on("RemoteInvitationRefused", (data) => {
          console.log("REMOTE INVITATION REFUSED: ", data);
        });
        data.on("RemoteInvitationFailure", (data) => {
          console.log("REMOTE INVITATION FAILURE: ", data);
        });
      });

      // Subscribes to the online statuses of all users apart from
      // the currently authenticated user
      this.rtmClient.subscribePeersOnlineStatus(
        this.users
          .map((user) => user.username)
          .filter((user) => user !== AUTH_USER)
      );

      this.rtmClient.on("PeersOnlineStatusChanged", (data) => {
        this.updatedOnlineStatus = data;
      });

      // Create a channel and listen to messages
      this.rtmChannelInstance = this.rtmClient.createChannel(
        this.rtmChannelName
      );

      // Join the RTM Channel
      this.rtmChannelInstance.join();

      this.rtmChannelInstance.on("ChannelMessage", (message, memberId) => {
        console.log("ChannelMessage");
        console.log("message: ", message);
        console.log("memberId: ", memberId);
      });

      this.rtmChannelInstance.on("MemberJoined", (memberId) => {
        console.log("MemberJoined");

        // check whether user exists before you add them to the online user list
        const joiningUserIndex = this.onlineUsers.findIndex(
          (member) => member === memberId
        );
        if (joiningUserIndex < 0) {
          this.onlineUsers.push(memberId);
        }
      });

      this.rtmChannelInstance.on("MemberLeft", (memberId) => {
        console.log("MemberLeft");
        console.log("memberId: ", memberId);
        const leavingUserIndex = this.onlineUsers.findIndex(
          (member) => member === memberId
        );
        this.onlineUsers.splice(leavingUserIndex, 1);
      });

      this.rtmChannelInstance.on("MemberCountUpdated", (data) => {
        console.log("MemberCountUpdated");
      });
    },

    async placeCall(calleeName) {
      // Get the online status of the user.
      // For our use case, if the user is not online we cannot place a call.
      // We send a notification to the caller accordingly.
      this.isCallingUser = true;

      this.callingUserNotification = `Calling ${calleeName}...`;
      const onlineStatus = await this.rtmClient.queryPeersOnlineStatus([
        calleeName,
      ]);

      if (!onlineStatus[calleeName]) {
        setTimeout(() => {
          this.callingUserNotification = `${calleeName} could not be reached`;

          setTimeout(() => {
            this.isCallingUser = false;
          }, 5000);
        }, 5000);
      } else {
        // Create a channel/room name for the video call
        const videoChannelName = `${AUTH_USER}_${calleeName}`;
        // Create LocalInvitation
        this.localInvitation = this.rtmClient.createLocalInvitation(calleeName);

        this.localInvitation.on(
          "LocalInvitationAccepted",
          async (invitationData) => {
            console.log("LOCAL INVITATION ACCEPTED: ", invitationData);

            // Generate an RTC token using the channel/room name
            const { data } = await this.generateToken(videoChannelName);
            // Initialize the agora RTC Client
            this.initializeRTCClient();
            // Join a room using the channel name. The callee will also join the room then accept the call
            await this.joinRoom(AGORA_APP_ID, data.token, videoChannelName);
            this.isCallingUser = false;
            this.callingUserNotification = "";
          }
        );

        this.localInvitation.on("LocalInvitationCanceled", (data) => {
          console.log("LOCAL INVITATION CANCELED: ", data);
          this.callingUserNotification = `${calleeName} cancelled the call`;
          setTimeout(() => {
            this.isCallingUser = false;
          }, 5000);
        });
        this.localInvitation.on("LocalInvitationRefused", (data) => {
          console.log("LOCAL INVITATION REFUSED: ", data);
          this.callingUserNotification = `${calleeName} refused the call`;
          setTimeout(() => {
            this.isCallingUser = false;
          }, 5000);
        });

        this.localInvitation.on("LocalInvitationReceivedByPeer", (data) => {
          console.log("LOCAL INVITATION RECEIVED BY PEER: ", data);
        });

        this.localInvitation.on("LocalInvitationFailure", (data) => {
          console.log("LOCAL INVITATION FAILURE: ", data);
          this.callingUserNotification = "Call failed. Try Again";
        });

        // set the channelId
        this.localInvitation.channelId = videoChannelName;

        // Send call invitation
        this.localInvitation.send();
      }
    },

    async cancelCall() {
      await this.localInvitation.cancel();
      this.isCallingUser = false;
    },

    async acceptCall() {
      // Generate RTC token using the channelId of the caller
      const { data } = await this.generateToken(
        this.remoteInvitation.channelId
      );

      // Initialize AgoraRTC Client
      this.initializeRTCClient();

      // Join the room created by the caller
      await this.joinRoom(
        AGORA_APP_ID,
        data.token,
        this.remoteInvitation.channelId
      );

      // Accept Call Invitation
      this.remoteInvitation.accept();
      this.incomingCall = false;
      this.callPlaced = true;
    },

    declineCall() {
      this.remoteInvitation.refuse();
      this.incomingCall = false;
    },

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

    /**
     * Agora Events and Listeners
     */
    initializeRTCClient() {
      this.rtcClient = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
    },

    async joinRoom(appID, token, channel) {
      try {
        await this.rtcClient.join(appID, channel, token, AUTH_USER);
        this.callPlaced = true;
        this.createLocalStream();
        this.initializeRTCListeners();
      } catch (error) {
        console.log(error);
      }
    },

    initializeRTCListeners() {
      //   Register event listeners
      this.rtcClient.on("user-published", async (user, mediaType) => {
        await this.rtcClient.subscribe(user, mediaType);

        // If the remote user publishes a video track.
        if (mediaType === "video") {
          // Get the RemoteVideoTrack object in the AgoraRTCRemoteUser object.
          this.remoteVideoTrack = user.videoTrack;
          this.remoteVideoTrack.play("remote-video");
        }
        // If the remote user publishes an audio track.
        if (mediaType === "audio") {
          // Get the RemoteAudioTrack object in the AgoraRTCRemoteUser object.term
          this.remoteAudioTrack = user.audioTrack;
          // Play the remote audio track. No need to pass any DOM element.
          this.remoteAudioTrack.play();
        }
      });

      this.rtcClient.on("user-unpublished", (data) => {
        console.log("USER UNPUBLISHED: ", data);
        // await this.endCall();
      });
    },

    async createLocalStream() {
      const [microphoneTrack, cameraTrack] =
        await AgoraRTC.createMicrophoneAndCameraTracks();
      await this.rtcClient.publish([microphoneTrack, cameraTrack]);
      cameraTrack.play("local-video");
      this.localAudioTrack = microphoneTrack;
      this.localVideoTrack = cameraTrack;
    },

    async endCall() {
      this.localAudioTrack.close();
      this.localVideoTrack.close();
      this.localAudioTrack.removeAllListeners();
      this.localVideoTrack.removeAllListeners();
      await this.rtcClient.unpublish();
      await this.rtcClient.leave();
      this.callPlaced = false;
    },

    async handleAudioToggle() {
      if (this.mutedAudio) {
        await this.localAudioTrack.setMuted(!this.mutedAudio);
        this.mutedAudio = false;
      } else {
        await this.localAudioTrack.setMuted(!this.mutedAudio);
        this.mutedAudio = true;
      }
    },

    async handleVideoToggle() {
      if (this.mutedVideo) {
        await this.localVideoTrack.setMuted(!this.mutedVideo);
        this.mutedVideo = false;
      } else {
        await this.localVideoTrack.setMuted(!this.mutedVideo);
        this.mutedVideo = true;
      }
    },
  },
});

index.css 中加入以下代码:

#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;
}

#btnGroup {
  flex-wrap: wrap;
}

声网Agora 呼叫页面的细分

在视频通话页面(app/templates/agora_rtm/index.html ) 上,我们显示了印有每个注册用户名字的按钮,并显示他们目前处于在线还是离线状态。

打出电话

点击想呼叫的用户的按钮,会看到一个呼出电话的界面,可以在这个界面取消通话。

2

被呼叫者/接收者会收到一个来电通知,他们可以拒绝(Decline)或接受(Accept)该呼叫。

3

技术解说视频

下面的视频解释了视频通话的逻辑:

4.在**.flaskenv** 中设置环境变量:

FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=
SQLALCHEMY_DATABASE_URI=sqlite:///db.sqlite
SQLALCHEMY_TRACK_MODIFICATIONS=False
TEMPLATES_AUTO_RELOAD=True
SEND_FILE_MAX_AGE_DEFAULT=0
ENABLE_DEBUG=True
AGORA_APP_ID=
AGORA_APP_CERTIFICATE=

测试

1.从终端启动 Flask 开发服务器。

flask run

2.打开两个不同的浏览器或同一浏览器的两个实例,其中一个实例处于隐身模式,找到注册页面:http://127.0.0.1:5000/register

3.在其中一个浏览器中注册四次,创建四个用户。

4.用刚创建的账户信息从登录页面登录:http://127.0.0.1:5000/login

5.找到 http://127.0.0.1:5000/agora_rtm.

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

7.在其中一个浏览器上点击附有用户名字的按钮来呼叫一个在线用户。

8.另一个用户经提示点击接受按钮,从而完成呼叫。

视频通话的视频演示

为保证你的 demo 能正常运行,请参考我的 demo 视频,了解完成的项目应该有的外观和功能。

总结

声网Agora RTM 和 RTC SDK 可以帮我们建立一个功能齐全的视频通话应用,还可以用 RTM SDK 实现应用内的信息传递。

测试过程中让我印象深刻的就是,通话双方的网络连接出现短暂故障时,通话会在故障解除后重新连接。

线上 demo 链接:https://watermark-remover.herokuapp.com/auth/login?next=%2Fagora_rtm

完成的项目库:GitHub - Mupati/agora-call-invitation: A Video Call implementation with Agora RTC and RTM SDKs

注意:确保 demo 链接或生产版本是通过 HTTPS 提供的,路由是 /agora_rtm

测试账户:

foo@example.com: DY6m7feJtbnx3ud

bar@example.com: Me3tm5reQpWcn3Q

其他资源

原文作者:Kofi Ocran
原文链接:Video Call Invitations with Agora RTM and RTC Using Vue JS and Flask

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