Flutter高仿微信系列共59篇,从Flutter客户端、Kotlin客户端、Web服务器、数据库表结构、Xmpp即时通讯服务器、视频通话服务器、腾讯云服务器全面讲解。
效果图:
目前市场上第三方视频接口的价格高的吓人
视频通话价格:
标清(SD) 14元/千分钟
高清(HD) 28元/千分钟
超高清(Full HD)63元/千分钟
2K 112元/千分钟
4K 252元/千分钟
这里的视频通话不接第三方sdk,自己实现的视频服务器。
详情请参考Flutter高仿微信-第29篇-单聊 , 这里只是提取视频通话的部分代码。
实现代码:
/** * Author : wangning * Email : maoning20080809@163.com * Date : 2022/9/25 14:46 * Description : 发起视频请求页面 */ class VideoCallWidget extends StatefulWidget { static String tag = 'video_call_widget'; //视频账号 final String videoPeerId; final String mediaFlag; String host = CommonUtils.BASE_IP; VideoCallWidget({required this.videoPeerId, required this.mediaFlag}); @override _VideoCallState createState() => _VideoCallState(); } class _VideoCallState extends State<VideoCallWidget> { Signaling? _signaling; String? _selfId; final RTCVideoRenderer _localRenderer = RTCVideoRenderer(); final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); bool _inCalling = false; Session? _session; DesktopCapturerSource? selected_source_; bool _waitAccept = false; bool _isExist = false; UserBean? userBean; //麦克风打开 bool isMic = true; //扬声器 bool isSpeaker = true; @override initState() { super.initState(); initRenderers(); _connect(context); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { Timer(Duration(seconds: 1),(){ _invitePeer(context, widget.videoPeerId, false); _inCalling = false; setState(() { }); }); }); loadUser(); _playVoice(); } void loadUser () async{ userBean = await UserRepository.getInstance().findUserByAccount(widget.videoPeerId); if(userBean != null){ setState(() { }); } } initRenderers() async { await _localRenderer.initialize(); await _remoteRenderer.initialize(); } @override deactivate() { super.deactivate(); _signaling?.close(); _localRenderer.dispose(); _remoteRenderer.dispose(); _timer?.cancel(); _stopVoice(); } void _connect(BuildContext context) async { LogUtils.d("connect开始 ${widget.mediaFlag}"); _signaling ??= Signaling(widget.host, context)..connect(); LogUtils.d("connect结束"); _signaling?.onSignalingStateChange = (SignalingState state) { LogUtils.d("video_call_sample onSignalingStateChange1: ${state}"); switch (state) { case SignalingState.ConnectionClosed: case SignalingState.ConnectionError: case SignalingState.ConnectionOpen: break; } }; _signaling?.onCallStateChange = (Session session, CallState state) async { LogUtils.d("video_call_sample onCallStateChange2:${state} , _waitAccept = ${_waitAccept}"); switch (state) { case CallState.CallStateNew: setState(() { _session = session; }); break; case CallState.CallStateRinging: bool? accept = await _showAcceptDialog(); if (accept!) { _accept(); setState(() { _inCalling = true; _processTimer(); }); } else { _reject(); } break; case CallState.CallStateBye: LogUtils.d("video_call_sample 挂断::${_waitAccept}, ${mounted}"); if(!_isExist){ Navigator.pop(context); } setState(() { _localRenderer.srcObject = null; _remoteRenderer.srcObject = null; _inCalling = false; _session = null; }); break; case CallState.CallStateInvite: _waitAccept = true; LogUtils.d("video_call_sample 邀请开始::${_waitAccept}"); break; case CallState.CallStateConnected: _stopVoice(); setState(() { _inCalling = true; _processTimer(); }); break; case CallState.CallStateRinging: } }; _signaling?.onPeersUpdate = ((event) { setState(() { _selfId = event['self']; LogUtils.d("video_call_sample 我的账号:${_selfId}"); }); }); _signaling?.onLocalStream = ((stream) { LogUtils.d("video_call_sample onLocalStream 3:"); _localRenderer.srcObject = stream; setState(() {}); }); _signaling?.onAddRemoteStream = ((_, stream) { LogUtils.d("video_call_sample onAddRemoteStream 4:"); _remoteRenderer.srcObject = stream; setState(() {}); }); _signaling?.onRemoveRemoteStream = ((_, stream) { LogUtils.d("video_call_sample onRemoveRemoteStream 5 :"); _remoteRenderer.srcObject = null; }); } Future<bool?> _showAcceptDialog() { return showDialog<bool?>( context: context, builder: (context) { return AlertDialog( title: Text("视频通话"), content: Text("是否接受好友的视频请求?"), actions: <Widget>[ TextButton( child: Text("拒绝"), onPressed: () => Navigator.of(context).pop(false), ), TextButton( child: Text("接受"), onPressed: () { Navigator.of(context).pop(true); }, ), ], ); }, ); } //开始播放视频声音 void _playVoice(){ final List<String> soundList = CommonUtils.getSoundList(); int selectedVideoCallId = SpUtils.getIntDefaultValue(CommonUtils.SETTING_VIDEO_CALL_ID, 2); bool videoCallSwitch = SpUtils.getBoolDefaultValue(CommonUtils.SETTING_VIDEO_CALL_SWITCH, true); //如果设置视频通话不响铃 if(!videoCallSwitch){ return; } //设置了视频通话响铃,但是选择无声音 if(videoCallSwitch && selectedVideoCallId == 0){ return; } String sound = "${soundList[selectedVideoCallId]}"; AudioPlayer.getInstance().playAsset("sounds/${sound}.mp3", isLoop:true, callback:(data){ LogUtils.d("播放视频声音:${data}"); }); } void _stopVoice(){ AudioPlayer.getInstance().stop(); } //显示邀请页面 Widget _showInvateWidget(){ return Container( child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ //SizedBox(height: 30,), Container( alignment: AlignmentDirectional.center, margin: EdgeInsets.only(top: 18), child: Column( children: [ //Image.asset(CommonUtils.getBaseIconUrlPng("wc_chat_speaker_open"), width: 28, height: 28,), Text("等待对方接受邀请.", style: TextStyle(fontSize: 18, color: Colors.black),), SizedBox(height: 30,), CommonAvatarView.showBaseImage(userBean?.avatar??"", 100, 100), SizedBox(height: 10,), Text("${userBean?.nickName}", style: TextStyle(fontSize: 26, color: Colors.black),), ], ), ), Container( margin: EdgeInsets.only(bottom: 40), alignment: AlignmentDirectional.center, child: FloatingActionButton( child: Icon(Icons.call_end), backgroundColor: Colors.pink, onPressed: _hangUp, ), ), ], ), ); } _invitePeer(BuildContext context, String peerId, bool useScreen) async { if (_signaling != null && peerId != _selfId) { LogUtils.d("video_call_sample 邀请:${peerId} - ${widget.mediaFlag}"); _signaling?.invite(peerId, 'video', widget.mediaFlag, useScreen); } } _accept() { LogUtils.d("video_call_sample 接受:${_session}"); if (_session != null) { _signaling?.accept(_session!.sid); } } _reject() { LogUtils.d("video_call_sample 拒绝:${_session}"); if (_session != null) { _signaling?.reject(_session!.sid); } } _hangUp() { LogUtils.d("video_call_sample 挂起:${_session} , ${_session?.sid}"); if (_session != null) { _signaling?.bye(_session!.sid); } _isExist = true; Navigator.pop(context); } _switchCamera() { LogUtils.d("video_call_sample 切换摄像头:${_session}"); _signaling?.switchCamera(); } _muteMic() { LogUtils.d("video_call_sample 音频:${_session}"); _signaling?.muteMic(); isMic = !isMic; setState(() { }); } enableSpeakerphone() { LogUtils.d("show_video_call 外放:_signaling = ${_signaling}"); _signaling?.enableSpeakerphone(); isSpeaker =!isSpeaker; setState(() { }); } Timer? _timer; //计时多少秒 int currentTimer = 0; //转换结果时间 String resultTimer = "00:00"; void _processTimer(){ if(_inCalling && widget.mediaFlag == CommonUtils.MEDIA_FLAG_VOICE){ _timer = Timer.periodic(Duration(seconds: 1), (timer) { currentTimer++; resultTimer = WnDateUtils.changeSecondToMMSS(currentTimer); setState(() { }); }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: WnAppBar.getAppBar(context, Text(widget.mediaFlag == CommonUtils.MEDIA_FLAG_VIDEO? '视频通话':'语音通话')), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: _inCalling ? SizedBox( width: double.infinity, child: Row( children: <Widget>[ //扬声器图标: https://www.iconfont.cn/search/index?searchType=icon&q=扬声器 getSwitchCameraWidget(), getHangUpWidget(), getMicWidget(), getSpeakerWidget(), ])) : null, body: _inCalling ? OrientationBuilder(builder: (context, orientation) { return Container( child: Stack(children: <Widget>[ Positioned( left: 0.0, right: 0.0, top: 0.0, bottom: 0.0, child: Offstage( offstage: widget.mediaFlag == CommonUtils.MEDIA_FLAG_VOICE, child:Container( margin: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0), width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: RTCVideoView(_remoteRenderer), decoration: BoxDecoration(color: Colors.black54), ) , ), ), Positioned( left: 20.0, top: 20.0, child: Offstage( offstage: widget.mediaFlag == CommonUtils.MEDIA_FLAG_VOICE, child: Container( width: orientation == Orientation.portrait ? 90.0 : 120.0, height: orientation == Orientation.portrait ? 120.0 : 90.0, child: RTCVideoView(_localRenderer, mirror: true), decoration: BoxDecoration(color: Colors.black54), ), ), ), Positioned( left: 20.0, right: 20.0, top: 30.0, child: Offstage( offstage: widget.mediaFlag == CommonUtils.MEDIA_FLAG_VIDEO, child: Container( width: orientation == Orientation.portrait ? 190.0 : 220.0, height: orientation == Orientation.portrait ? 220.0 : 190.0, child: Column( children: [ Text("${resultTimer}", style: TextStyle(fontSize: 20, color: Colors.grey.shade500),), SizedBox(height: 40,), CommonAvatarView.showBaseImage(userBean?.avatar??"", 80, 80), SizedBox(height: 8,), Text("${userBean?.nickName}", style: TextStyle(fontSize: 18, color: Colors.black),), ], ), ), ), ) ]), ); }) : _showInvateWidget(), ); } //切换摄像头 Widget getSwitchCameraWidget(){ return Expanded(child: Container( width: 80, height: 100, child: Column( children: [ FloatingActionButton( child: const Icon(Icons.switch_camera), onPressed: _switchCamera, ), SizedBox(height: 10,), Text("切换摄像头", style: TextStyle(fontSize: 12, color: Colors.white),), ], ), )); } //挂断 Widget getHangUpWidget(){ return Expanded(child: Container( width: 80, height: 100, child: Column( children: [ FloatingActionButton( child: Icon(Icons.call_end), backgroundColor: Colors.pink, onPressed: _hangUp, ), SizedBox(height: 10,), Text("挂 断", style: TextStyle(fontSize: 12, color: Colors.white),), ], ), )); } //麦克风 Widget getMicWidget(){ return Expanded(child: Container( width: 80, height: 100, child: Column( children: [ FloatingActionButton( child: Icon(isMic?Icons.mic:Icons.mic_off), onPressed: _muteMic, ), SizedBox(height: 10,), Text(isMic?"麦克风已开":"麦克风已关", style: TextStyle(fontSize: 12, color: Colors.white),), ], ), )); } //扬声器 Widget getSpeakerWidget(){ return Expanded(child: Container( width: 80, height: 100, child: Column( children: [ FloatingActionButton( child: Image.asset(CommonUtils.getBaseIconUrlPng(isSpeaker?"wc_chat_speaker_open":"wc_chat_speaker_close"), width: 28, height: 28,), onPressed: enableSpeakerphone, ), SizedBox(height: 10,), Text(isSpeaker?"扬声器已开":"扬声器已关", style: TextStyle(fontSize: 12, color: Colors.white),), ], ), )); } }