基于七牛RTN实现多人在线会议或课堂(一)
一、 七牛实时音视频云介绍
1、产品架构
客户端SDK:主要负责客户端的音视频采集、渲染、滤镜处理、编解码、传输等工作,客户可以快速集成到自己 App 中,让自己的应用具备音视频通话的能力。 支持 Android、iOS、Web ,集成 SDK 就可实现音视频的采集、编解码、渲染播放等工作。
服务端REST API和SDK:主要提供房间管理、状态回调等基本的业务功能,另外还提供鉴黄鉴暴、质量分析等配套功能 只需要集成对应语言的服务端 SDK 即可以管理实时音视频互动房间、调用配套数据处理服务、向客户端通知音视频流和数据处理的状态。
2、信令传输
实时通话交互流程如下:
因此,服务端需要开发的主要工作如下:
1.为用户创建通话房间,并将通话房间和对应主播的Id关联起来;
2.计算加入房间的roomToken并提供给App,该roomToken是结合uerId、roomName等信息使用七牛的AccessKey和SecretKey按照一定的规则生成;
3.提供通话的业务逻辑,如:通话请求/应答业务逻辑、服务端房间管理和踢人等;
4.关于roomToken的计算方法及RTC Server API的说明可查阅《七牛实时音视频云服务端 API 接口规范》。
3、产品相关资料
RTN Demo体验:https://doc.qnsdk.com/rtn/docs/demo
七牛实时音视频云web SDK官方地址:https://doc.qnsdk.com/rtn/web
音视频云接入流程:https://doc.qnsdk.com/rtn/docs/rtn_startup
web SDK接入文档:https://doc.qnsdk.com/rtn/web/docs/sdk_overview.html
二、Vue前端项目中开发准备
1、引入SDK
这里通过npm引入SDK:
npm install --save pili-rtc-web
如果想更新到最新版本或指定版本,运行如下命令:
npm install --save pili-rtc-web@latest # 最新版本 npm install --save pili-rtc-web@2.0.0 # 指定版本
2、在单页面应用中引入
<template> </template> <script> import * as QNRTC from "pili-rtc-web"; export default { name: 'VideoConference', }, mounted() { console.log("current version is", QNRTC.version); } </script>
访问对应页面地址,看到打印的 current version 表示引入成功。
3、关于async/await
SDK 在对外 API 以及之后的示例中都会默认使用 async/await 的异步方案。
这里以最常用的异步操作 setTimeout 为例,分别介绍 Promise/Async/Await 这三种异步方案。
这里编写了 2 个函数,功能都是调用 setTimeout
然后 1s 后执行相关代码。前者是最常用的 Callback 模式,后者是一个 Promise:
const setTimeoutCallback = (callback) => { setTimeout(callback, 1000); } const setTimeoutPromise = () => new Promise(resolve => { setTimeout(resolve, 1000); })
然后利用上面的函数分别用三种异步方案实现一个需求——每隔一秒钟打印一行字,重复 3 次。
(1)callback方案
setTimeoutCallback(() => { console.log("text!"); setTimeoutCallback(() => { console.log("text!"); setTimeoutCallback(() => { console.log("text!"); }); }); });
(2)promise方案
// promise setTimeoutPromise() .then(() => { console.log("text!"); return setTimeoutPromise(); }).then(() => { console.log("text!"); return setTimeoutPromise(); }).then(() => { console.log("text!"); });
(3)async/await方案
// async/await (async () => { await setTimeoutPromise(); console.log("text!"); await setTimeoutPromise(); console.log("text!"); await setTimeoutPromise(); console.log("text!"); })();
在这种场景下 async/await 的写法会更加简洁优雅。在七牛的 SDK 中,大部分都是这种串行异步的场景,也推荐使用 async/await 的写法。
4、Track模式和Stream模式对比
Web 端选择封装了两套模式的 API 供用户选择。一套为 Stream 模式
,也就是用户和流一对一关系的模式。一套为 Track 模式
,也就是用户和 Track 一对多关系的模式。
需要注意的是,这 2 个模式只有在 Web SDK 下 的 API 中有区别,在实际实现上,都是通过以 Track 为单位操作流实现的。只是在 Stream 模式
下,只允许用户发布至多一个音频 Track 和至多一个视频 Track。
(1)什么情况使用Track模式
需求场景满足以下情况时,优先使用Track模式:
- 不清楚需求边界的情况
- 需要一个用户同时发布多路视频画面或多路音频画面
- 用户订阅时需要有动态订阅逻辑,即纯音频订阅、纯视频订阅等
- 虽同时刻只有一个视频画面,但有画面切换的需求
(2)什么情况使用Stream模式
需求场景满足以下情况时,优先使用Stream模式:
- 明确需求中一个用户最多只发布一路视频和一路音频
- 没有动态订阅需求,远端发布什么就订阅什么
- 老版本用户希望升级v2
三、房间管理
由于我需要实现多人在线会议场景,因此必须选用Track模式实现。
1、从后端获取token
SDK通过传入RoomToken来完成加入房间的。这个RoomToken是包含连麦所需要的主要信息:七牛的账户标识、连麦的应用 ID(appId)、连麦的房间号 (roomName)、连麦的用户名(userId)、有效期等。
<template> </template> <script> import * as QNRTC from "pili-rtc-web"; export default { name: 'VideoConference', data() { return { } }, methods: { httpGetList: function () { var self = this; this.$httpGet(this.$http, "users/teachingprocController/getClassroomInfo", this.$trimJson(self.queryInfo), function (ret) { if (ret.currentTimetableInfo != undefined) { self.queryInfo = ret; self.queryInfo.currentStudentList = []; var timenow = Date.parse(new Date()); if (timenow >= ret.currentTimetableInfo.endTime || ret.currentTimetableInfo.status == 3) { self.classroomType = 2; //2 表示直播已结束 } else if (timenow > ret.currentTimetableInfo.beginTime && timenow < ret.currentTimetableInfo.endTime) { self.classroomType = 0; //0 表示正在直播 self.token = ret.currentTimetableInfo.realParam; self.joinRoom(self.token); } else if (timenow < ret.currentTimetableInfo.beginTime) { self.classroomType = 1; //1 表示尚未开始直播 //为尚未开始直播时,开启倒计时定时器 if (!!self.counterTimer) { clearInterval(self.counterTimer); self.counterTimer = setInterval(self.handleCounter, 1000); } else { self.counterTimer = setInterval(self.handleCounter, 1000); } } } else { self.classroomType = 3; // 3 表示点播课堂 } }); }, created() { let self = this; this.currentUser = this.$sessionUser.fetch();// 获取url中携带的值 if (Object.keys(this.$route.query).length > 0) { if (this.$route.query.classroomno != undefined) { this.queryInfo.classroomno = this.$route.query.classroomno; } if (this.$route.query.timetableno != undefined) { this.queryInfo.timetableno = this.$route.query.timetableno; } // 获取token this.httpGetList(); } }, } </script>
在用户跳转到该页面时,created在实例创建完成后被立即调用,根据url携带的值触发httpGetList函数,根据课程类型执行joinRoom方法。
2、实例化全局房间session对象
加入房间之前,需要实例化一个全局 Session 对象。之后所有和房间相关的操作都会通过调用这个对象的方法来实现。
import * as QNRTC from "pili-rtc-web"; const myRoom = new QNRTC.TrackModeSession();
3、加入房间
通过前面获取的token加入房间。
methods: { // 创建房间 async joinRoom(token) { // 初始化一个房间Session对象,这里使用Track模式 const myRoom = new QNRTC.TrackModeSession(); this.myRoom = myRoom; // 使用 RoomToken加入房间 await myRoom.joinRoomWithToken(token); // 自动加载视频 this.publish(); this.autoSubscribe(myRoom); }, }
4、离开房间
离开房间后 SDK 会自动和房间断开连接并销毁所有订阅音视频对象,但不会清理采集到的音视频对象。
如果离开房间后想再次加入房间,重新调用加入房间的方法。
<script> import * as QNRTC from "pili-rtc-web"; export default { name: 'VideoConference', // 略 beforeDestroy() { // 销毁释放本地track for (let localTrack of this.localTracks) { localTrack.release(); } // 离开房间 this.myRoom.leaveRoom(); } } </script>
监听窗口关闭事件,在每次页面即将被关闭或刷新时自动离开房间。
配合浏览器的 onbeforeunload 事件在每次页面即将被关闭或者刷新时自动离开房间。
<script> import * as QNRTC from "pili-rtc-web"; export default { name: 'VideoConference', methods: { beforeunloadHandler(){ this._beforeUnload_time=new Date().getTime(); // 销毁释放本地track for (let localTrack of this.localTracks) { localTrack.release(); } // 离开房间 this.myRoom.leaveRoom(); } }, mounted() { // 略 // 监听窗口关闭事件 window.addEventListener('beforeunload', e => this.beforeunloadHandler(e)) }, } </script>
5、踢人
以管理员身份签发的RoomToken加入,可以通过如下方法强制将其他用户踢出房间。
await myRoom.kickoutUser("USERID"); // 暂时没用到
四、页面模板设计
1、页面设计目标
2、模板结构
<template> <div class="meeting"> <div class="meeting-wraper"> <div class="meeting-header"> <h4>多人在线视频直播</h4> </div> <div class="meeting-content"> <div class="main-meeting"> <div id="maintracks" class="maintracks" v-show="!whitePadStatus"></div> </div> <div class="meeting-member"> <!-- 其他人的头像 --> <div class="meeting-item" v-for="(remoteUser, index) in remoteUserList" :key="index" v-show="remoteUser.isLive"> <div :id="remoteUser.userId"></div> <div :id="remoteUser.userId + '_vol'" style="visibility:hidden;"></div> <div class="btn-box" v-if="currentUser.userType == 1"> <button @click="setSpeaker(remoteUser.userId)"> <span v-if="remoteUser.screenStatus" key="1" class="iconfont iconjinzhitouping"></span> <span v-else key="2" class="iconfont iconzhibo"></span> </button> <button @click="setAudio(remoteUser.userId)"> <!-- 有声音,显示静音图标 --> <span v-if="!remoteUser.audioStatus" key="1" class="iconfont iconjingyin"></span> <!-- 无声音,显示声音图标 --> <span v-else key="2" class="iconfont iconyingliang"></span> </button> <button @click="setWhitePad(remoteUser.userId)"> <!-- 禁止使用画板绘画 --> <span v-if="!remoteUser.drawStatus" key="1" class="iconfont iconsousuo"></span> <!-- 默认不能使用画板绘画,授权可以使用画板绘画 --> <span v-else key="2" class="iconfont iconzu1"></span> </button> <button @click="exitMeeting(remoteUser.userId)"> <span class="iconfont icontuichu"></span> </button> </div> </div> </div> </div> <div class="meeting-list"> <!-- 自己的头像 --> <div class="self_wraper"> <div id="localtracks" class="self_camera"></div> <div id="local_vol_tracks" style="visibility:hidden;"></div> <div class="btn-box"> <button @click="setSpeaker('localtracks')" :disabled="localScreenDisable"> <span v-if="localScreenStatus" key="1" class="iconfont iconjinzhitouping"></span> <span v-else class="iconfont iconzhibo"></span> </button> <button @click="setAudio('localtracks')" :disabled="localAudioDisable"> <!-- 有声音,显示静音图标 --> <span v-if="!localAudioStatus" key="1" class="iconfont iconjingyin"></span> <!-- 无声音,显示声音图标 --> <span v-else key="2" class="iconfont iconyingliang"></span> </button> <button @click="setWhitePad('localtracks')"> <span class="iconfont iconzu1"></span> </button> <button @click="exitMeeting('localtracks')"><span class="iconfont icontuichu"></span></button> </div> </div> </div> </div> </div> </div> </template>