1
0
mirror of https://gitlab.com/comunic/comunicmobile synced 2024-12-28 13:58:51 +00:00
comunicmobile/lib/ui/screens/call_screen.dart

727 lines
21 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:comunic/helpers/calls_helper.dart';
import 'package:comunic/helpers/conversations_helper.dart';
import 'package:comunic/helpers/events_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/call_members_list.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/call_config.dart';
import 'package:comunic/models/call_member.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/plugins_interface/wake_lock.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/widgets/safe_state.dart';
import 'package:comunic/utils/account_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
/// Call screen
///
/// @author Pierre Hubert
enum _PopupMenuOption { STOP_STREAMING, SWITCH_CAMERA }
class CallScreen extends StatefulWidget {
final int convID;
/// This settings should be always true except for the small call window...
final bool floatingButtons;
/// Use custom application bar. This function takes as parameter a nullable
/// String which is the title of the conversation
final PreferredSizeWidget Function(String) buildCustomAppBar;
/// Execute custom action when the call is closed
///
/// The default behavior is to pop the page
final void Function() onClose;
const CallScreen({
Key key,
@required this.convID,
this.floatingButtons = true,
this.buildCustomAppBar,
this.onClose,
}) : assert(convID != null),
assert(convID > 0),
assert(floatingButtons != null),
super(key: key);
@override
_CallScreenState createState() => _CallScreenState();
}
class _CallScreenState extends SafeState<CallScreen> {
// Widget properties
int get convID => widget.convID;
// State properties
Conversation _conversation;
String _convName;
CallConfig _conf;
var _error = false;
CallMembersList _membersList;
UsersList _usersList;
final _peersConnections = Map<int, RTCPeerConnection>();
final _renderers = Map<int, RTCVideoRenderer>();
MediaStream _localStream;
var _isLocalStreamVisible = true;
var _hideMenuBars = false;
bool get _canHideMenuBar =>
_hideMenuBars &&
_canMakeVideoCall &&
_renderers.keys.where((f) => f != userID()).length > 0;
bool get _canMakeVideoCall =>
_conversation.callCapabilities == CallCapabilities.VIDEO;
bool get isStreamingAudio =>
_localStream != null && _localStream.getAudioTracks().length > 0;
bool get isStreamingVideo =>
_localStream != null && _localStream.getVideoTracks().length > 0;
bool get isAudioMuted =>
isStreamingAudio && !_localStream.getAudioTracks()[0].enabled;
bool get isVideoMuted =>
isStreamingVideo && !_localStream.getVideoTracks()[0].enabled;
@override
void initState() {
super.initState();
_initCall();
}
@override
void dispose() {
super.dispose();
_endCall();
}
void _initCall() async {
try {
setState(() => _error = false);
// First, load information about the conversation
_conversation =
await ConversationsHelper().getSingle(convID, force: true);
_convName =
await ConversationsHelper.getConversationNameAsync(_conversation);
assert(_convName != null);
setState(() {});
// Join the call
await CallsHelper.join(convID);
// Get call configuration
_conf = await CallsHelper.getConfig();
// Get current members of the call
final membersList = await CallsHelper.getMembers(convID);
membersList.removeUser(userID());
_usersList = await UsersHelper().getListWithThrow(membersList.usersID);
_membersList = membersList;
setState(() {});
// Register to events
this.listen<UserJoinedCallEvent>((e) async {
if (e.callID != convID) return;
try {
if (!_usersList.hasUser(e.userID))
_usersList.add(await UsersHelper().getSingleWithThrow(e.userID));
setState(() => _membersList.add(CallMember(userID: e.userID)));
} catch (e, stack) {
print("$e\n$stack");
}
});
this.listen<UserLeftCallEvent>((e) {
if (e.callID == convID) _removeMember(e.userID);
});
this.listen<NewCallSignalEvent>((e) {
if (e.callID == convID) _newSignal(e);
});
this.listen<CallPeerReadyEvent>((e) {
if (e.callID == convID) _memberReady(e.peerID);
});
this.listen<CallPeerInterruptedStreamingEvent>((e) {
if (e.callID == convID) _removeRemotePeerConnection(e.peerID);
});
this.listen<CallClosedEvent>((e) {
if (e.callID == convID) _leaveCall(needConfirm: false);
});
// Connect to ready peers
for (final peer in _membersList.readyPeers)
await this._memberReady(peer.userID);
setState(() {});
// Lock device await
await setWakeLock(true);
} catch (e, stack) {
print("Could not initialize call! $e\n$stack");
setState(() => _error = true);
}
}
/// Do clean up operations when call screen is destroyed
void _endCall() async {
try {
await setWakeLock(false);
// Close all ready members
for (final member in _membersList.readyPeers)
await _removeMember(member.userID);
// Close local stream
await _stopStreaming();
// Leave the call
await CallsHelper.leave(convID);
} catch (e, stack) {
print("Could not end call properly! $e\n$stack");
}
}
/// Make us leave the call
void _leaveCall({bool needConfirm = true}) async {
if (needConfirm &&
!await showConfirmDialog(
context: context,
message: tr("Do you really want to leave this call ?"))) return;
if (widget.onClose == null)
MainController.of(context).popPage();
else
widget.onClose();
}
/// Start streaming on our end
Future<void> _startStreaming(bool includeVideo) async {
try {
await _stopStreaming();
// Request user media
_localStream = await navigator.mediaDevices.getUserMedia({
"audio": true,
"video": !includeVideo
? false
: {
"mandatory": {
"maxWidth": '320',
"maxHeight": '240',
"minFrameRate": '24',
},
"facingMode": "user",
"optional": [],
}
});
// Start renderer
_renderers[userID()] = RTCVideoRenderer();
await _renderers[userID()].initialize();
_renderers[userID()].srcObject = _localStream;
setState(() {});
// Open stream
final peerConnection = await createPeerConnection(_conf.pluginConfig, {
"mandatory": {},
"optional": [],
});
_peersConnections[userID()] = peerConnection;
for (final track in _localStream.getTracks()) {
await peerConnection.addTrack(track, _localStream);
}
peerConnection.onAddStream =
(s) => throw Exception("Got a new stream on main peer connection!");
peerConnection.onIceCandidate =
(c) => CallsHelper.sendIceCandidate(convID, userID(), c);
peerConnection.onIceConnectionState = (c) {
print("New connection state: $c");
if (c == RTCIceConnectionState.RTCIceConnectionStateConnected) {
// Add a delay of two seconds to avoid racing
Timer(Duration(seconds: 2), () => CallsHelper.markPeerReady(convID));
}
};
peerConnection.onSignalingState = (c) => print("New signaling state: $c");
// Create & send offer
final offer = await peerConnection.createOffer({
"mandatory": {
"OfferToReceiveAudio": true,
"OfferToReceiveVideo": includeVideo,
},
"optional": [],
});
await peerConnection.setLocalDescription(offer);
await CallsHelper.sendSessionDescription(convID, userID(), offer);
} catch (e, stack) {
print("Could not start streaming! $e\n$stack");
showSimpleSnack(context, tr("Could not start streaming!"));
}
}
/// Stop local streaming
Future<void> _stopStreaming() async {
// Close peer connection
if (_peersConnections.containsKey(userID())) {
_peersConnections[userID()].close();
_peersConnections.remove(userID());
await CallsHelper.notifyStoppedStreaming(convID);
}
// Stop local stream
if (_localStream != null) {
await _localStream.dispose();
_localStream = null;
}
// Close renderer
if (_renderers.containsKey(userID())) {
await _renderers[userID()].dispose();
_renderers.remove(userID());
}
}
/// Toggle menubar visibility
void _toggleMenuBarsVisibility() {
setState(() {
_hideMenuBars = !_hideMenuBars;
});
}
/// Toggle local video streaming
Future<void> _toggleStreaming(bool isVideo) async {
if (isVideo && !_canMakeVideoCall) {
print("Attempting to switch video call on a non-capable video stream!");
return;
}
// Start streaming if required
if (!isStreamingAudio || (!isStreamingVideo && isVideo)) {
await _startStreaming(isVideo);
}
// Toggle appropriate mute
else {
if (!isVideo)
_localStream.getAudioTracks()[0].enabled =
!_localStream.getAudioTracks()[0].enabled;
else
_localStream.getVideoTracks()[0].enabled =
!_localStream.getVideoTracks()[0].enabled;
}
setState(() {});
}
/// Call this when a user started to stream media
Future<void> _memberReady(int memberID) async {
try {
_membersList.getUser(memberID).status = MemberStatus.READY;
setState(() {});
// Create peer connection
final peerConnection = await createPeerConnection(_conf.pluginConfig, {
"mandatory": {
"OfferToReceiveAudio": true,
"OfferToReceiveVideo": _canMakeVideoCall,
},
"optional": [],
});
_peersConnections[memberID] = peerConnection;
// Create a renderer
_renderers[memberID] = RTCVideoRenderer();
await _renderers[memberID].initialize();
// Register callbacks
peerConnection.onIceCandidate =
(c) => CallsHelper.sendIceCandidate(convID, memberID, c);
peerConnection.onAddStream = (s) {
setState(() {
_membersList.getUser(memberID).stream = s;
_renderers[memberID].srcObject = s;
});
};
// Request an offer to establish a peer connection
await CallsHelper.requestOffer(convID, memberID);
setState(() {});
} catch (e, stack) {
print("Could not connect to remote peer $e\n$stack!");
showSimpleSnack(context, tr("Could not connect to a remote peer!"));
}
}
/// Call this method each time we get a new signal
void _newSignal(NewCallSignalEvent ev) async {
try {
// Check if we can process this message
if (!_peersConnections.containsKey(ev.peerID)) {
print(
"Could not process a signal for the connection with peer ${ev.peerID}!");
return;
}
// Check the kind of signal
// SessionDescription
if (ev.sessionDescription != null) {
await _peersConnections[ev.peerID]
.setRemoteDescription(ev.sessionDescription);
// Send answer if required
if (ev.sessionDescription.type == "offer") {
final answer = await _peersConnections[ev.peerID].createAnswer({});
await _peersConnections[ev.peerID].setLocalDescription(answer);
await CallsHelper.sendSessionDescription(convID, ev.peerID, answer);
}
}
// Ice Candidate
else {
await _peersConnections[ev.peerID].addCandidate(ev.candidate);
}
} catch (e, stack) {
print("Error while handling new signal ! $e\n$stack");
showSimpleSnack(context, tr("Error while processing new signal!"));
}
}
/// Call this when a user has interrupted streaming
Future<void> _removeRemotePeerConnection(int memberID) async {
final member = _membersList.getUser(memberID);
member.status = MemberStatus.JOINED;
setState(() {});
if (_peersConnections.containsKey(memberID)) {
await _peersConnections[memberID].close();
_peersConnections.remove(memberID);
}
if (_renderers.containsKey(memberID)) {
await _renderers[memberID].dispose();
_renderers.remove(memberID);
}
if (member.stream != null) {
member.stream.dispose();
member.stream = null;
}
}
/// Call this when a member has completely left the call
Future<void> _removeMember(int memberID) async {
await _removeRemotePeerConnection(memberID);
_membersList.removeUser(memberID);
setState(() {});
}
/// Toggle local stream visibility
void _toggleLocalStreamVisibility() {
setState(() {
_isLocalStreamVisible = !_isLocalStreamVisible;
});
}
/// Handles menu selection
void _handleSelectedMenuOption(_PopupMenuOption option) async {
switch (option) {
// Switch camera
case _PopupMenuOption.SWITCH_CAMERA:
await Helper.switchCamera(_localStream.getVideoTracks()[0]);
break;
// Stop streaming
case _PopupMenuOption.STOP_STREAMING:
await _stopStreaming();
setState(() {});
break;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTap: () => _toggleMenuBarsVisibility(),
child: Scaffold(
appBar: _canHideMenuBar ? null : _buildAppBar(),
body: _buildBody(),
),
);
}
/// Build application bar
PreferredSizeWidget _buildAppBar() {
if (widget.buildCustomAppBar != null)
return widget.buildCustomAppBar(_convName);
return AppBar(
leading: IconButton(
icon: Icon(Icons.arrow_back),
onPressed: () => _leaveCall(),
),
title: _convName == null ? CircularProgressIndicator() : Text(_convName),
);
}
/// Build widget body
Widget _buildBody() {
// Handle errors
if (_error)
return buildErrorCard(tr("Could not initialize call!"), actions: [
MaterialButton(
onPressed: () => _initCall(),
child: Text(tr("Try again").toUpperCase()),
)
]);
// Check if are not ready to show call UI
if (_membersList == null) return buildCenteredProgressBar();
return Column(
children: <Widget>[
// Members list
_canHideMenuBar ? Container() : _buildMembersArea(),
// Build videos area (+ buttons if floating)
Expanded(child: LayoutBuilder(builder: _buildVideosArea)),
// Buttons bar (if buttons are not floating)
!_canHideMenuBar && !widget.floatingButtons
? _buildFooterArea()
: Container(),
],
);
}
/// Build members area
Widget _buildMembersArea() {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: RichText(
text: TextSpan(
children: _membersList
.map((f) => TextSpan(
text: _usersList.getUser(f.userID).displayName + " ",
style: TextStyle(
color: f.status == MemberStatus.JOINED
? (darkTheme() ? Colors.white : Colors.black)
: Colors.green)))
.toList())),
),
);
}
/// Videos area
Widget _buildVideosArea(BuildContext context, BoxConstraints constraints) {
final availableVideos = _membersList.readyPeers
.where((f) => f.hasVideoStream && _renderers.containsKey(f.userID))
.toList();
final rows = <Row>[];
var numberRows = sqrt(availableVideos.length).ceil();
var numberCols = numberRows;
if (availableVideos.length == 2) numberRows = 1;
final videoWidth = constraints.maxWidth / numberCols;
final videoHeight = constraints.maxHeight / numberRows;
for (int i = 0; i < numberRows; i++) {
rows.add(Row(
children: availableVideos
.getRange(i * numberRows,
min(availableVideos.length, i * numberRows + numberCols))
.map((f) => Container(
width: videoWidth,
height: videoHeight,
child: _buildMemberVideo(f.userID),
))
.toList(),
));
}
return Stack(
fit: StackFit.expand,
children: [
// Remote peers videos
Column(
children: rows,
),
// Local peer video
isStreamingVideo && _isLocalStreamVisible
? _buildLocalVideo()
: Container(),
// Buttons bar (if floating)
!_canHideMenuBar && widget.floatingButtons
? Positioned(
bottom: 10, right: 0, left: 0, child: _buildFooterArea())
: Container(),
],
);
}
Widget _buildMemberVideo(int peerID) {
return RTCVideoView(_renderers[peerID]);
}
Widget _buildLocalVideo() {
return Positioned(
child: RTCVideoView(_renderers[userID()]),
height: 50,
width: 50,
right: 10,
bottom: (_canHideMenuBar || !widget.floatingButtons ? 10 : 80),
);
}
/// Footer area
Widget _buildFooterArea() {
return Container(
color: !widget.floatingButtons ? Colors.black : null,
height: !widget.floatingButtons ? 40 : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// Show / hide user video button
_FooterButton(
visible: _canMakeVideoCall,
icon: Icon(_isLocalStreamVisible
? Icons.visibility
: Icons.visibility_off),
roundedButtons: widget.floatingButtons,
onPressed: () => _toggleLocalStreamVisibility(),
),
// Toggle audio button
_FooterButton(
onPressed: () => _toggleStreaming(false),
icon: Icon(
isStreamingAudio && !isAudioMuted ? Icons.mic : Icons.mic_off),
roundedButtons: widget.floatingButtons,
),
// Hang up call
_FooterButton(
width: 60,
icon: Icon(Icons.phone),
roundedButtons: widget.floatingButtons,
bgColor: Colors.red.shade900,
onPressed: () => _leaveCall(),
),
// Toggle video button
_FooterButton(
visible: _canMakeVideoCall,
icon: Icon(isStreamingVideo && !isVideoMuted
? Icons.videocam
: Icons.videocam_off),
roundedButtons: widget.floatingButtons,
onPressed: () => _toggleStreaming(true),
),
// Interrupt local streaming
PopupMenuButton<_PopupMenuOption>(
offset: Offset(0, -110),
itemBuilder: (c) => [
// Switch camera
PopupMenuItem(
enabled: isStreamingVideo,
child: Text(tr("Switch camera")),
value: _PopupMenuOption.SWITCH_CAMERA),
// Interrupt streaming
PopupMenuItem(
child: Text(tr("Stop streaming")),
value: _PopupMenuOption.STOP_STREAMING)
],
child: _FooterButton(
icon: Icon(Icons.menu, color: Colors.white),
onPressed: null,
roundedButtons: widget.floatingButtons,
),
onSelected: (d) => _handleSelectedMenuOption(d),
)
],
),
);
}
}
class _FooterButton extends StatelessWidget {
final Function() onPressed;
final Widget icon;
final bool visible;
final double width;
final Color bgColor;
final bool roundedButtons;
const _FooterButton({
Key key,
@required this.icon,
@required this.onPressed,
this.visible = true,
this.width = 45,
this.bgColor = Colors.black,
@required this.roundedButtons,
}) : assert(icon != null),
assert(visible != null),
assert(roundedButtons != null),
super(key: key);
@override
Widget build(BuildContext context) {
if (!visible) return Container();
return roundedButtons ? _buildFAB() : _buildRectangleButton();
}
Widget _buildFAB() => Padding(
padding: const EdgeInsets.only(left: 6, right: 6),
child: Container(
width: width,
child: FloatingActionButton(
child: icon,
foregroundColor: Colors.white,
onPressed: onPressed == null ? null : () => onPressed(),
backgroundColor: bgColor,
),
));
Widget _buildRectangleButton() => MaterialButton(
onPressed: onPressed,
child: icon,
color: bgColor,
textColor: Colors.white,
);
}