如果你想在网页应用程序中加入视频通话功能,就一定不要错过这篇文章。
本文我们将使用声网Agora Web SDK 搭建一个网页应用,且让应用具有与 WhatsApp 网页版类似的视频通话功能。
本文搭建的功能
-
用来电通知邀请用户加入视频通话
-
如果用户不在线,就返回相应信息
-
在电话接通前终止通话
-
拒绝/挂断通话
-
接受/接听通话
前期准备
-
一个声网Agora 开发者账户(可查看:如何注册并开始使用声网Agora SDK).
-
一个带有认证的 Python 3.8 Flask 应用程序。首先,下载下面的 starter 项目:Flask Auth Starter Project,我们将在这个 starter 项目的基础上搭建项目。
项目设置
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 ) 上,我们显示了印有每个注册用户名字的按钮,并显示他们目前处于在线还是离线状态。
打出电话
点击想呼叫的用户的按钮,会看到一个呼出电话的界面,可以在这个界面取消通话。
被呼叫者/接收者会收到一个来电通知,他们可以拒绝(Decline)或接受(Accept)该呼叫。
技术解说视频
下面的视频解释了视频通话的逻辑:
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
其他资源
-
Agora RTC Client 上的可用事件。
-
想了解更多关于 Agora 应用程序的信息,请查看声网Agora Quickstart Guides。
-
想了解本文讨论的功能或其他功能,请查看完整文档:Agora Web SDK API。
原文作者:Kofi Ocran
原文链接:Video Call Invitations with Agora RTM and RTC Using Vue JS and Flask