使用声网 Flutter SDK 把当前说话者高亮显示(视频聊天SDK)
要想会议或者视频聊天顺畅进行,必须得考虑以下几个因素:
第一、网络连接必须得稳定;
第二、观众得能清晰听到说话者说的内容;
第三、观众得知道是谁在说话。
其实在多人会议和视频聊天时我们经常搞不清楚究竟是谁在说话,这样就很难顺畅沟通,所以对任何视频通话程序来说,添加一个能把当前说话者高亮显示的功能(视频聊天SDK)都非常重要。
如果你使用声网 SDK 搭建视频通话应用,你会发现开发一个高亮显示功能非常简单。在下面这个教程里,我们会使用声网 Flutter SDK 实现在通话过程中把当前说话者高亮显示的功能。
一、前期准备
- 了解 Flutter 的基础知识。
- 任何 IDE (例如:Android Studio 或 Visual Studio Code )
- 声网 Flutter SDK
- 声网开发者帐户(详细步骤可参考这篇文章)。
二、项目设置
1. 在 IDE 中创建一个 Flutter 项目,去掉样板代码。
2. 在 pubspec.yaml
文件中添加下列依赖,然后通过运行 pub get
来安装这些依赖:
dependencies:
flutter:
sdk: flutter
permission_handler: ^8.1.4+2
agora_rtc_engine: ^4.0.6
3. 像下图这样在 lib 文件夹里创建文件结构:
项目结构
三、视频通话界面
众所周知,Flutter 应用要在 main.dart 文件里进行初始化并调用在 home_page.dart
文件中定义的 HomePage()
:
import 'package:agora_flutter_who_is_speaking/pages/home_page.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Agora flutter Video call ',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: HomePage(),
);
}
}
main.dart
四、搭建首页
首先,创建一个 dart 文件,将其命名为 home_page.dart
,本页面会接收用户输入的频道名。
用户点击加入按钮后,我们的应用就会请求用户授予摄像头和麦克风权限,如果用户同意授权,该用户的频道名就会被传递到 call_page.dart:
import 'dart:async';
import 'package:agora_flutter_who_is_speaking/pages/call_page.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
class HomePage extends StatefulWidget {
@override
State<StatefulWidget> createState() => HomePageState();
}
class HomePageState extends State<HomePage> {
/// create a channelController to retrieve text value
final _channelController = TextEditingController();
/// if channel textField is validated to have an error
bool _validateError = false;
@override
void dispose() {
// dispose input controller
_channelController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Agora Flutter video Call'),
),
body: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _channelController,
decoration: InputDecoration(
errorText:
_validateError ? 'Channel name is mandatory' : null,
border: UnderlineInputBorder(
borderSide: BorderSide(width: 1),
),
hintText: 'Channel name',
),
))
],
),
SizedBox(
height: 12,
),
Row(
children: <Widget>[
Expanded(
child: ElevatedButton(
onPressed: onJoin,
child: Text('Join'),
),
)
],
)
],
),
),
);
}
}
如果用户在首页点击加入按钮,就会触发 onJoin()
方法,这个方法会验证用户输入并调用_handleCameraAndMic(..)
方法来获取摄像头和麦克风权限,用户同意授权后,用户的频道名就会被传递到 call_page.dart:
这里我们使用 permission_handler 插件向用户请求权限并查看用户状态:
Future<void> _handleCameraAndMic(Permission permission) async {
final status = await permission.request();
print(status);
}
五 、搭建通话页面
创建一个名称为 call_page.dart
的 dart 文件,我们将在这个页面上开发全部功能,包括:在 grid 视图中显示表面视图、把通话中的当前说话者高亮显示以及设置一些用户交互按钮如结束通话、麦克风静音/取消静音、和翻转摄像头:
import 'package:agora_flutter_who_is_speaking/model/user.dart';
import 'package:agora_flutter_who_is_speaking/utils/settings.dart';
import 'package:agora_rtc_engine/rtc_engine.dart';
import 'package:agora_rtc_engine/rtc_local_view.dart' as RtcLocalView;
import 'package:agora_rtc_engine/rtc_remote_view.dart' as RtcRemoteView;
import 'package:flutter/material.dart';
class CallPage extends StatefulWidget {
/// non-modifiable channel name of the page
final String? channelName;
const CallPage({Key? key, this.channelName}) : super(key: key);
@override
_CallPageState createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
late RtcEngine _engine;
Map<int, User> _userMap = new Map<int, User>();
bool _muted = false;
int? _localUid;
@override
void initState() {
super.initState();
// initialize agora SDK
initialize();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Group call"),
),
body: Stack(
children: [_buildGridVideoView(), _toolbar()],
),
);
}
}
为了解我们使用的变量,我们来一起看一下上面用到的代码。
我们的 CallPage 构造函数包含了首页的频道名的值:
_engine
是对 RtcEngine 类的引用。_userMap
维护加入频道的用户的数据。_muted
提供麦克风是否被静音的布尔状态。_localUid
包含本地用户的 Uid。
大家都知道,只有当状态部件被添加到部件树上时,initState()
方法才会被调用。 这里我们调用的是 initialize()
方法:
Future<void> initialize() async {
if (APP_ID.isEmpty) {
print("'APP_ID missing, please provide your APP_ID in settings.dart");
return;
}
await _initAgoraRtcEngine();
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.channelName ?? "", null, 0);
}
在上面的代码段里, initialize()
方法负责调用下列方法:
_initAgoraRtcEngine()
负责初始化 Rtc 引擎。_addAgoraEventHandlers()
负责事件处理。joinChannel()
负责加入特定频道。
Future<void> _initAgoraRtcEngine() async {
_engine = await RtcEngine.create(APP_ID);
await _engine.enableVideo();
await _engine.setChannelProfile(ChannelProfile.Communication);
// Enables the audioVolumeIndication
await _engine.enableAudioVolumeIndication(250, 3, true);
}
create(..)
:AgoraRtc 引擎通过 create(…) 方法进行初始化,参数为 App ID,我们从声网Agore 开发者控制台获得 App ID,然后通过这个 App ID 把我们的应用连接到 Agora 引擎上(App ID 保存在 settings.dart
文件中)。
/// Define App ID
const APP_ID = "Your App ID";
settings.dart
enableVideo()
: 启动视频模块。我们可以在加入频道前或在通话过程中调用这个方法。如果在加入频道前调用这个方法,应用就会以视频模式启动;如果在音频通话过程中调用这个方法,应用会从音频模式切换到视频模式。
setChannelProfile(..):
我们使用的是 communication
频道模式,这是 1 对 1 通话或群组通话的默认模式。
enableAudioVolumeIndication(..):
在设定的时间区间内开启 RtcEngineEventHandler.audioVolumeIndication
回调来报告哪些用户正在说话以及该说话者的音量。这个方法包含三个参数: interval, smooth, and report_vad:
- int interval: 设置两个连续的音量指示之间的时间区间。声网Agora 推荐把时间区间设置为 ≥ 200 毫秒,如果时间区间 ≤ 0,音量指示就会被禁用。
- int smooth: 这个平滑系数对音频音量指示器的灵敏度进行设置,该值的变化范围在 0 到 10 之间,该值越大,则指示器的灵敏度越高,声网Agora 建议将该值设定为 3。
- bool report_vad:如果该值为 true,RtcEngineEventHandler.audioVolumeIndication 回调会报告本地用户的声音活动状态,如果该值为 false,该回调函数就不会监测本地用户的声音活动状态。report_vad 的默认值是false 。
void _addAgoraEventHandlers() {
_engine.setEventHandler(
RtcEngineEventHandler(error: (code) {
print("error occurred $code");
}, joinChannelSuccess: (channel, uid, elapsed) {
setState(() {
_localUid = uid;
_userMap.addAll({uid: User(uid, false)});
});
}, leaveChannel: (stats) {
setState(() {
_userMap.clear();
});
}, userJoined: (uid, elapsed) {
setState(() {
_userMap.addAll({uid: User(uid, false)});
});
}, userOffline: (uid, elapsed) {
setState(() {
_userMap.remove(uid);
});
},
/// Detecting active speaker by using audioVolumeIndication callback
audioVolumeIndication: (volumeInfo, v) {
//core logic will be here
}),
);
}
_addAgoraEventHandler():
负责 RtcEngine 的事件回调方法,该事件回调方法通过调用 setEventHandler()
对事件处理器进行设置,引擎的事件处理器设置完成后,我们就可以监听引擎事件并接收相应的 RtcEngine 数据。
在设置回调方法之前,我们还需要创建一个名称为 user.dart
的 dart 文件,用来捕捉用户的说话状态。
class User {
int uid; //reference to user uid
bool isSpeaking; // reference to whether the user is speaking
User(this.uid, this.isSpeaking);
@override
String toString() {
return 'User{uid: $uid, isSpeaking: $isSpeaking}';
}
}
事件处理器的回调方法包括:
error()
回调是为了在 SDK 运行时进行报错。joinChannelSuccess()
在本地用户加入特定频道时会被触发。这个方法会返回本地用户加入频道的频道名称、用户 uid、所用时间(单位为毫秒)等数据。
_localUid = uid;
_userMap.addAll({uid: User(uid, false)});
本地用户的 uid 会被分配给 _localUid ,用户数据则被添加到一个以用户 uid 作为 key、以用户对象作为值的 userMap
中,uid 和 false 是该 userMap
的状态。
- leaveChannel() :用户离开频道会启动 leaveChannel() 。它会返回 RtcStats,RtcStats 包括通话持续时间、传输和接收的字节数以及延迟等数据。用户离开频道后,我们会清除该用户的 _userMap。
- userJoined() 远端用户加入特定频道会触发userJoined() ,它会返回远端用户加入频道的用户 uid、所用时间(单位为毫秒)等数据。
_userMap.addAll({uid: User(uid, false)});
远端用户的数据会被添加进一个以用户 uid 作为 key、用户对象作为值的 _userMap
中,uid 和 false 是该 userMap
的状态。
- userOffline() :远端用户离开频道时会触发这个方法,它会返回用户 uid 以及用户离线原因,这里我们使用 uid 把用户从 _userMap 中移除。
- audioVolumeIndication() :无论用户是否说话,这个回调都会在设置的时间区间内被触发并报告哪个用户正在说话、说话者的音量以及是否是本地用户在说话。这个回调默认处于关闭状态。我们可以通过调用 RtcEngine.enableAudioVolumeIndication 方法开启它:
只有当 RtcEngine.enableAudioVolumeIndication
方法中的 report_vad
值被设置为 true
时,才能监测本地用户。
如果本地用户调用 RtcEngine.muteLocalAudioStream
方法,SDK 会停止触发本地用户的回调。一个远端说话者调用 RtcEngine.muteLocalAudioStream
方法 20 秒后,远端说话者的回调就不再包含该远端用户的信息;所有远端用户调用 RtcEngine.muteLocalAudioStream
方法20秒后,SDK 会停止触发远端说话者的回调。
audioVolumeIndication()
方法可以把所有当前说话者都高亮显示,但是 activeSpeaker()
回调可以把其中一位说话者高亮显示,一般来说,该回调会返回当前说话者中音量最高的人。
AudioVolumeCallback 包括两个参数:
- List<AudioVolumeInfo>speakers: 是一个包含每位说话者的用户 ID 和音量信息的数组,本地用户的 uid 是 0。
- int totalVolume: 是混音后的总音量,该值的变化范围在 0(最低音量)到 255(最高音量)之间。
audioVolumeIndication: (volumeInfo, v) {
volumeInfo.forEach((speaker) {
//detecting speaking person whose volume more than 5
if (speaker.volume > 5) {
try {
_userMap.forEach((key, value) {
//Highlighting local user
//In this callback, the local user is represented by an uid of 0.
if ((_localUid?.compareTo(key) == 0) && (speaker.uid == 0)) {
setState(() {
_userMap.update(key, (value) => User(key, true));
});
}
//Highlighting remote user
else if (key.compareTo(speaker.uid) == 0) {
setState(() {
_userMap.update(key, (value) => User(key, true));
});
} else {
setState(() {
_userMap.update(key, (value) => User(key, false));
});
}
});
} catch (error) {
print('Error:${error.toString()}');
}
}
});
}
上面的代码根据条件验证来更新 userMap
,例如,如果说话者的 uid 跟 _userMap
里的任何 uid 匹配,则该 uid 对应的用户对象就会被更新为 true,反之则被更新为 false,例如:
_userMap.update(key, (value) => User(key, true))
无论用户处于正在讲话或退出通话状态,_userMap
都会随时更新,并根据用户状态来维护用户数据,所以我们可以根据 _userMap
的数据来更新 UI。
GridView _buildGridVideoView() {
return GridView.builder(
shrinkWrap: true,
itemCount: _userMap.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: MediaQuery.of(context).size.height / 1100,
crossAxisCount: 2),
itemBuilder: (BuildContext context, int index) => Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
child: Container(
color: Colors.white,
child: (_userMap.entries.elementAt(index).key == _localUid)
? RtcLocalView.SurfaceView()
: RtcRemoteView.SurfaceView(
uid: _userMap.entries.elementAt(index).key)),
decoration: BoxDecoration(
border: Border.all(
color: _userMap.entries.elementAt(index).value.isSpeaking
? Colors.blue
: Colors.grey,
width: 6),
borderRadius: BorderRadius.all(
Radius.circular(10.0),
),
),
),
),
);
}
我们在 _userMap
的数据的基础上使用 GridView
来显示 SurfaceViews,把通话中正在说话的人高亮显示,如果 isSpeaking
值为 true
,就代表对应的用户正在说话,那么该用户的容器的颜色就会变成蓝色。
Widget _toolbar()
在屏幕底部添加用户交互按钮,用来结束通话、麦克风静音/取消静音以及切换摄像头:
Widget _toolbar() {
return Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 48),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RawMaterialButton(
onPressed: _onToggleMute,
child: Icon(
_muted ? Icons.mic_off : Icons.mic,
color: _muted ? Colors.white : Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: _muted ? Colors.blueAccent : Colors.white,
padding: const EdgeInsets.all(12.0),
),
RawMaterialButton(
onPressed: () => _onCallEnd(context),
child: Icon(
Icons.call_end,
color: Colors.white,
size: 35.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.redAccent,
padding: const EdgeInsets.all(15.0),
),
RawMaterialButton(
onPressed: _onSwitchCamera,
child: Icon(
Icons.switch_camera,
color: Colors.blueAccent,
size: 20.0,
),
shape: CircleBorder(),
elevation: 2.0,
fillColor: Colors.white,
padding: const EdgeInsets.all(12.0),
)
],
),
);
}
_onToggleMute
可以通过调用_engine.muteLocalAudioStream()
方法来触发本地音频流,该方法含有一个布尔值。
void _onToggleMute() {
setState(() {
_muted = !_muted;
});
_engine.muteLocalAudioStream(_muted);
}
_onCallEnd
可以切断通话并返回首页:
void _onCallEnd(BuildContext context) {
Navigator.pop(context);
}
_onSwitchCamera()
通过触发_engine.switchCamera()
方法来切换前置摄像头和后置摄像头:
void _onSwitchCamera() {
_engine.switchCamera();
}
最后,我们调用 _engine.destroy()
方法销毁频道,用 _engine.leaveChannel()
方法同意用户离开频道,然后用 dispose()
方法清理用户数据:
@override
void dispose() {
//clear users
_userMap.clear();
// destroy sdk
_engine.leaveChannel();
_engine.destroy();
super.dispose();
}
六、测试:
完成编码之后,我们就可以在设备上测试这个应用啦,记得在我们的 IDE 里运行这个项目来进行测试。
总结
我们成功地用声网 Flutter SDK 在 Flutter 视频通话应用中实现了把当前说话者高亮显示的功能(视频聊天SDK)。
你可以点击 这里 获取完成的源代码。
其他资源
原文作者:Satyam Parasa Satyam Parasa 来自印度,是一位网页端/手机端应用开发人员,喜欢学习新科技,出于对科技的热爱,他在 Flutter 上建立了一个名为 flutterant.com 的博客。
原文链接:Highlighting the Active Speaker using the Agora Flutter SDK