import 'dart:async'; import 'dart:convert'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:agora_rtc_engine/rtc_engine.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:will_play/utils/auth.dart'; import 'package:web_socket_channel/io.dart'; import '../../utils/settings.dart'; import '../../utils/http_util.dart'; import '../../utils/showToast.dart'; // 一:实现 RtcEngine 语音主要五个步骤逻辑: // 1.初始化引擎 // 2.开启音频权限,启用音频模块 // 3.创建房间 // 4.设置事件监听(成功加入房间,是否有用户加入,用户是否离开,用户是否掉线) // 5.布局实现 // 6.退出语音(根据需要销毁引擎,释放资源) // 二:使用 WebSocket 通信分为四个步骤: // 1.连接到WebSocket服务器 // 2.监听来自服务器的消息 // 3.将数据发送到服务器 // 4.关闭WebSocket连接 class PalRoomPage extends StatefulWidget { final String agoraToken; final String userName; final String channelName; final ClientRole? role; PalRoomPage({ Key? key, this.agoraToken = '', this.userName = '', this.channelName = '', this.role, }) : super(key: key); @override State createState() => _PalRoomPageState(); } class _PalRoomPageState extends State { // final _users = []; // 用户id数组 final _infoStrings = []; // 状态文字信息列表 bool viewPanel = true; // 是否显示状态文字信息列表 bool muted = false; // 是否开麦 bool volume = true; // 音量 TextEditingController _controller = TextEditingController(); // 输入框内容 late RtcEngine _engine; // RtcEngine 声网实例变量 late IOWebSocketChannel _wsChannel; // webSocket 实例变量 late Timer timer; // 轮询定时器 // 房间座位 List _list = [ {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, {'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png'}, ]; // 连麦用户列表数据 List newUserList = []; @override void initState() { super.initState(); initialize(); } @override void dispose() { super.dispose(); // _users.clear(); _engine.leaveChannel(); _engine.destroy(); _wsChannel.sink.close(); timer.cancel(); } Future initialize() async { // _infoStrings.insert(0, { // 'type': 'system', // 'content': // '|令牌:${widget.agoraToken}|用户名:${widget.userName}|房间:${widget.channelName}|身份:${widget.role}' // }); // 判断appid是否存在 if (APP_ID.isEmpty) { setState(() { _infoStrings.insert(0, { 'type': 'system', 'content': 'APP ID缺失,请在settings.dart中提供您的APP ID' }); _infoStrings.insert(0, {'type': 'system', 'content': 'Agora引擎没有启动'}); }); return; } if (widget.role == ClientRole.Broadcaster) { _infoStrings.insert(0, {'type': 'system', 'content': '您身份为主播,已经上麦状态'}); } else if (widget.role == ClientRole.Audience) { _infoStrings.insert(0, {'type': 'system', 'content': '您身份为观众,点击麦克风图标上麦'}); } _engine = await RtcEngine.create(APP_ID); // 初始化引擎 await _engine.enableAudio(); // 启用音频模块 await _engine.setDefaultAudioRouteToSpeakerphone( true); // 设置默认的音频路由:该方法设置接收到的音频从听筒或扬声器出声。如果用户不调用本方法,音频默认从听筒出声。 await _engine.setAudioProfile(AudioProfile.SpeechStandard, AudioScenario.ChatRoomEntertainment); // 设置音频编码属性和音频场景 await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting); await _engine.setClientRole(widget.role!); _addAgoraEventHandlers(); // 调用状态文字信息列表方法 // if (widget.role == ClientRole.Broadcaster) { // await _engine.setClientRole(ClientRole.Broadcaster); // } String uid = await Storage.get('id'); await _engine.joinChannel( widget.agoraToken, widget.channelName, null, int.parse(uid)); // 加入频道 timer = Timer.periodic(Duration(seconds: 1), (timer) async { _lodingCreate(); }); } void _lodingCreate() async { var res = await MyHttpUtil().get("/chat/api/room/create/${widget.channelName}"); String userName = await Storage.get('userName'); // 用户名 print('_lodingCreate:${res.data}'); if (res.data == true) { timer.cancel(); wsInitalize(); _detail(); } } Future wsInitalize() async { Map headers = new Map(); var token = await Storage.get('token'); print('/=/=/=/=/=//=/=/=/=/=//=/=/=/=/=//=/=/=/=/=/'); headers['Authorization'] = 'Bearer ${token}'; print(headers); print(widget.channelName); _wsChannel = IOWebSocketChannel.connect( 'ws://101.35.117.69:9093/chat/api/chat/room/${widget.channelName}', headers: headers, ); // 建立链接 print('/=/=/=/=/=//=/=/=/=/=//=/=/=/=/=//=/=/=/=/=/'); _wsChannel.stream.listen((data) { var information = jsonDecode(data); print('收到服务器数据:${information}'); setState(() { _infoStrings.insert(0, { 'type': 'speak', 'content': '${information['message']}', 'name': '${information['nickname']}' }); }); }, onDone: () { print('连接关闭时响应'); }, onError: (error) { print('发生错误'); }, cancelOnError: true); // 监听来自服务器的消息 } // 获取房间详情接口 void _detail() async { var res = await MyHttpUtil().get("/chat/api/room/detail/${widget.channelName}"); // print('${res}'); var _userList = res.data['userList']; // 赋值image字段头像 _userList.forEach((item) { item['image'] = 'images/palRoom/user.png'; item['flag'] = true; }); // 筛选出身份类型为主播的用户 newUserList = _userList.where((item) => item['type'] != 'audience').toList(); print('-----------------筛选出身份类型为主播的用户:${newUserList}-----------------'); for (var i = 0; i < _list.length; i++) { if (newUserList.length < (i + 1)) { setState(() { _list[i] = { 'nickname': '', 'flag': false, 'image': 'images/palRoom/voice2.png' }; }); } else { setState(() { _list[i] = newUserList[i]; }); } } print('-----------------${_list}-----------------'); // // 房间空无一人的时候返回首页 // if (newUserList.isEmpty) { // Navigator.of(context).pushReplacementNamed('/'); // return; // } } void _addAgoraEventHandlers() { _engine.setEventHandler(RtcEngineEventHandler( error: (code) { // 错误 setState(() { final info = '错误 $code'; _infoStrings.insert(0, {'type': 'system', 'content': info}); }); }, userJoined: (uid, elapsed) { _detail(); // 用户加入 setState(() { final info = '用户用户$uid加入房间'; _infoStrings.insert(0, {'type': 'system', 'content': info}); // _users.add(uid); }); }, joinChannelSuccess: (channel, uid, elapsed) { _detail(); // 加入频道成功 setState(() { final info = '用户$uid加入频道号$channel成功'; _infoStrings.insert(0, {'type': 'system', 'content': info}); }); }, leaveChannel: (stats) { _detail(); // 离开频道 setState(() { _infoStrings.insert(0, {'type': 'system', 'content': '离开频道'}); // _users.clear(); }); }, userOffline: (uid, reason) { _detail(); // 用户离开当前频道 setState(() { final info = '用户$uid离开当前频道, reason: $reason'; _infoStrings.insert(0, {'type': 'system', 'content': info}); // _users.remove(uid); }); }, clientRoleChanged: (oldRole, newRole) { _detail(); // 直播场景下用户角色切换成功回调 setState(() { final info = '用户旧身份$oldRole,用户新身份$newRole'; _infoStrings.insert(0, {'type': 'system', 'content': info}); }); }, rejoinChannelSuccess: (channel, uid, elapsed) { _detail(); // 成功重新加入频道回调 setState(() { final info = '用户$uid重新加入成功'; _infoStrings.insert(0, {'type': 'system', 'content': info}); // _users.add(uid); }); }, warning: (warn) { // 报告 RtcChannel 的警告码 print(warn); }, tokenPrivilegeWillExpire: (token) { // Token 服务将在30s内过期回调 // app 应重新获取 Token,然后调用 _engine.renewToken(token) 将新的 Token 传给 SDK print('即将服务失效的 Token:${token}'); }, requestToken: () { // Token 已过期回调 setState(() { final info = 'Token 已过期'; _infoStrings.insert(0, {'type': 'system', 'content': info}); }); }, )); } // 头部 Widget _head() { // 底部弹框组件 _modelBottomSheet(index) { showModalBottomSheet( context: context, builder: (context) { return Container( height: 150.0, child: Column( children: [ _list[index]['flag'] == false ? ListTile( title: Text( "连线上麦", textAlign: TextAlign.center, ), onTap: () { // setState(() { // for (var i = 0; i < _list.length; i++) { // if (i == index) { // _list[index]['flag'] = true; // _list[index]['nickname'] = '_users'; // _list[index]['image'] = // 'images/palRoom/user.png'; // } else { // _list[i]['flag'] = false; // _list[i]['name'] = ''; // _list[i]['image'] = 'images/palRoom/voice2.png'; // } // } // }); // 申请连麦需要改变身份为主播! _engine.setClientRole(ClientRole.Broadcaster); setState(() { _list[index]['flag'] = true; }); Navigator.pop(context); }, ) : ListTile( title: Text( "下麦", textAlign: TextAlign.center, ), onTap: () { setState(() { _list[index]['flag'] = false; _list[index]['nickname'] = ''; _list[index]['image'] = 'images/palRoom/voice2.png'; }); // 下麦再将身份改回为观众(下麦不代表离开频道) _engine.setClientRole(ClientRole.Audience); Navigator.pop(context); // // 房间空无一人的时候返回首页 if (newUserList.isEmpty) { Navigator.of(context).pushReplacementNamed('/'); return; } }, ), ListTile( title: Text( "取消", textAlign: TextAlign.center, ), onTap: () { Navigator.pop(context); }), ], ), ); }, ); } return Container( child: ListView( children: [ // --房主 start -- Container( padding: EdgeInsets.fromLTRB(0, 27, 0, 5), alignment: Alignment.topCenter, child: Image.asset( 'images/palRoom/user.png', width: 70, height: 70, ), ), Container( margin: EdgeInsets.only(bottom: 30), alignment: Alignment.topCenter, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 18, height: 18, alignment: Alignment.center, decoration: BoxDecoration( color: Color.fromRGBO(245, 173, 29, 1), borderRadius: BorderRadius.circular(2), ), child: Text( '房', style: TextStyle( color: Colors.white, fontSize: 12, ), ), ), SizedBox(width: 5), Text(_list.length > 0 ? _list[0]['nickname'] : '加入中', style: TextStyle(color: Colors.white, fontSize: 14)), ], ), ), // --房主 end -- // -- 网格 start -- Container( // padding: EdgeInsets.only(left: 15, right: 15), child: GridView.builder( shrinkWrap: true, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisSpacing: 40, mainAxisSpacing: 30, crossAxisCount: 4, childAspectRatio: 1 / 1.25, ), itemCount: _list.length, itemBuilder: (BuildContext context, int index) { return GestureDetector( onTap: () { setState(() { if (widget.role == ClientRole.Audience) { // 观众身份的时候点击上麦克图标 _modelBottomSheet(index); // 调用底部弹框方法 } else { _infoStrings.insert( 0, {'type': 'system', 'content': '您身份为主播,已经上麦状态'}); } }); }, child: Column( children: [ Container( width: 55, height: 55, margin: EdgeInsets.only(bottom: 5), decoration: BoxDecoration( color: Color.fromRGBO(52, 51, 69, 1), borderRadius: BorderRadius.circular(50), ), child: Center( child: ClipOval( child: Image.asset( _list[index]['image'], width: _list[index]['flag'] ? 55 : 14, height: _list[index]['flag'] ? 55 : 21, ), ), ), ), Text( _list[index]['nickname'], textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontSize: 12), ), ], ), ); }, ), ), // -- 网格 end -- ], ), ); } // 消息列表 Widget _planel() { _filtering(type, content, {name}) { switch (type) { case 'system': return Container( child: Text( '系统公告:$content', style: TextStyle(color: Color.fromRGBO(155, 154, 170, 1)), ), ); case 'speak': return Container( child: Text( '${name}的聊天消息:$content', style: TextStyle(color: Color.fromRGBO(155, 154, 170, 1)), ), ); case 'gift': return Container( child: Text( '礼物消息:$content', style: TextStyle(color: Color.fromRGBO(155, 154, 170, 1)), ), ); } } return Visibility( visible: viewPanel, child: Container( padding: EdgeInsets.symmetric(vertical: 60), alignment: Alignment.bottomCenter, child: FractionallySizedBox( heightFactor: 0.5, child: Container( // decoration: BoxDecoration( // color: Colors.black, // ), child: ListView.builder( reverse: true, itemCount: _infoStrings.length, itemBuilder: (BuildContext context, int index) { if (_infoStrings.isEmpty) { return Text('null'); } return Padding( padding: EdgeInsets.symmetric(vertical: 5, horizontal: 15), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Container( padding: EdgeInsets.symmetric( vertical: 10, horizontal: 10, ), decoration: BoxDecoration( color: Color.fromRGBO(50, 51, 71, 1), borderRadius: BorderRadius.circular(5), ), child: _filtering(_infoStrings[index]['type'], _infoStrings[index]['content'], name: _infoStrings[index]['name']), ), ), ], ), ); }, ), ), ), ), ); } // 底部输入框控制台 Widget _footer() { return Container( width: double.infinity, alignment: Alignment.bottomCenter, padding: EdgeInsets.symmetric( vertical: 18, horizontal: 15, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ GestureDetector( // 音量图标 onTap: () { setState(() { volume = !volume; }); _engine.adjustPlaybackSignalVolume( volume ? 100 : 0); // 调节本地播放的所有远端用户信号音量 }, child: Icon(volume ? Icons.volume_up : Icons.volume_off, color: Color.fromRGBO(201, 201, 201, 1), size: 25.0), ), GestureDetector( // 麦克风图标 onTap: () { setState(() { muted = !muted; }); _engine.muteLocalAudioStream(muted); // 取消或恢复发布本地音频流(是否静音) }, child: Icon(muted ? Icons.mic_off : Icons.mic, color: Color.fromRGBO(201, 201, 201, 1), size: 25.0), ), GestureDetector( // 表情图标 child: Icon(Icons.tag_faces, color: Color.fromRGBO(201, 201, 201, 1), size: 25.0), ), Container( // 输入框 width: 220, padding: EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: Color.fromRGBO(52, 51, 69, 1), borderRadius: BorderRadius.circular(30), ), child: TextField( textInputAction: TextInputAction.send, // 键盘右下角图标 onSubmitted: (value) { late String speak = _controller.text.trim(); if (speak.isEmpty) { showToast("请输入聊天内容~"); return; } print(speak); _wsChannel.sink.add(speak); _controller.clear(); }, controller: _controller, decoration: InputDecoration( isDense: true, border: InputBorder.none, hintText: '我来说几句', hintStyle: TextStyle( fontSize: 12, color: Color.fromRGBO(138, 138, 146, 1), ), ), style: TextStyle( fontSize: 12, color: Colors.white, ), ), ), GestureDetector( // 加号图标 onTap: () { late String speak = _controller.text.trim(); if (speak.isEmpty) { showToast("请输入聊天内容~"); return; } print(speak); _wsChannel.sink.add(speak); _controller.clear(); }, child: Icon(Icons.add, color: Color.fromRGBO(201, 201, 201, 1), size: 25.0), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( leading: IconButton( icon: Icon(Icons.arrow_back_ios), onPressed: () { print('离开'); _engine.setClientRole(ClientRole.Audience); _engine.leaveChannel(); Navigator.of(context).pop(); }), elevation: 0, // z轴阴影 titleSpacing: 0, // 标题与其他控件的间隔 backgroundColor: Color.fromRGBO(27, 28, 48, 1), title: Container( height: 60, decoration: BoxDecoration( color: Color.fromRGBO(27, 28, 48, 1), ), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ SizedBox( child: Padding( padding: EdgeInsets.only(right: 5), child: ClipOval( child: Image.asset( 'images/palRoom/user.png', width: 46, height: 46, ), ), ), ), Expanded( flex: 1, child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Row( children: [ Text( _list.length > 0 ? _list[0]['nickname'] : '加入中', style: TextStyle(color: Colors.white, fontSize: 12), ), SizedBox(width: 5), Text( 'ID309060', style: TextStyle( color: Color.fromRGBO(242, 174, 30, 1), fontSize: 10, ), ), ], ), Row( children: [ Text.rich( TextSpan( style: TextStyle( fontSize: 10, color: Colors.white, ), children: [ TextSpan(text: '交友'), TextSpan(text: ' | '), TextSpan(text: '房间名:成人避风港'), ]), ), SizedBox(width: 5), Container( padding: EdgeInsets.fromLTRB(6, 2, 6, 2), decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(2)), color: Color.fromRGBO(47, 47, 59, 1), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '在线:121', style: TextStyle( fontSize: 10, color: Colors.white, ), ), SizedBox(width: 5), Image.asset( 'images/palRoom/whiteRight.png', width: 5, height: 8, ), ], ), ) ], ), ], ), ) ], ), ), actions: [ IconButton( onPressed: () { print('more图标被点击'); setState(() { viewPanel = !viewPanel; }); }, icon: Image.asset( 'images/palRoom/more.png', fit: BoxFit.cover, width: 20.0, height: 5.0, ), ), ], ), backgroundColor: Color.fromRGBO(27, 28, 48, 1), body: Stack( children: [ _head(), _planel(), _footer(), ], ), ); } }