/*
* Famedly
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import 'dart:async' hide StreamView;
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:just_audio/just_audio.dart';
import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as darthtml;
import 'package:vibration/vibration.dart';
import 'package:wakelock/wakelock.dart';
import 'package:fluffychat/pages/voip/dialer/pip/pip_view.dart';
import 'package:fluffychat/pages/voip/group_call_view.dart';
import 'package:fluffychat/pages/voip/utils/call_session_state.dart';
import 'package:fluffychat/pages/voip/utils/call_state_proxy.dart';
import 'package:fluffychat/pages/voip/utils/group_call_session_state.dart';
import 'package:fluffychat/pages/voip/utils/stream_view.dart';
import 'package:fluffychat/pages/voip/utils/voip_plugin.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
class Calling extends StatefulWidget {
final VoipPlugin voipPlugin;
final VoidCallback onClear;
const Calling({Key? key, required this.voipPlugin, required this.onClear})
: super(key: key);
@override
MyCallingPage createState() => MyCallingPage();
}
class MyCallingPage extends State {
late CallStateProxy? proxy;
late Room room;
String get displayName => proxy?.displayName ?? '';
MediaStream? get localStream {
if (proxy != null && proxy!.localUserMediaStream != null) {
return proxy!.localUserMediaStream!.stream!;
}
return null;
}
bool get isMicrophoneMuted => proxy?.isMicrophoneMuted ?? false;
bool get isLocalVideoMuted => proxy?.isLocalVideoMuted ?? false;
bool get isScreensharingEnabled => proxy?.isScreensharingEnabled ?? false;
bool get isRemoteOnHold => proxy?.isRemoteOnHold ?? false;
bool get voiceonly => proxy?.voiceonly ?? false;
bool get connecting => proxy?.connecting ?? false;
bool get connected => proxy?.connected ?? false;
bool get ended => proxy?.ended ?? true;
bool get callOnHold => proxy?.callOnHold ?? false;
bool get isGroupCall => (proxy != null && proxy! is GroupCallSessionState);
bool get showMicMuteButton => connected;
bool get showScreenSharingButton => connected;
bool get showHoldButton => connected && !isGroupCall;
WrappedMediaStream get screenSharing => screenSharingStreams.elementAt(0);
WrappedMediaStream? get primaryStream => proxy?.primaryStream;
bool get showAnswerButton =>
(!connected && !connecting && !ended) &&
!(proxy?.isOutgoing ?? false) &&
!isGroupCall;
bool get showVideoMuteButton =>
proxy != null &&
(proxy?.localUserMediaStream?.stream?.getVideoTracks().isNotEmpty ??
false) &&
connected;
List get screenSharingStreams =>
(proxy?.screenSharingStreams ?? []);
List get userMediaStreams {
if (isGroupCall) {
return (proxy?.userMediaStreams ?? []);
}
final streams = [
...proxy?.screenSharingStreams ?? [],
...proxy?.userMediaStreams ?? []
];
streams
.removeWhere((s) => s.stream?.id == proxy?.primaryStream?.stream?.id);
return streams;
}
String get title {
if (isGroupCall) {
return 'Group call';
}
return '${voiceonly ? 'Voice Call' : 'Video Call'} (${proxy?.callState ?? 'Could not detect call state'})';
}
String get heldTitle {
var heldTitle = '';
if (proxy?.localHold ?? false) {
heldTitle = '${proxy?.displayName ?? ''} held the call.';
} else if (proxy?.remoteOnHold ?? false) {
heldTitle = 'You held the call.';
}
return heldTitle;
}
// bool get speakerOn => call?.speakerOn ?? false;
// bool get mirrored => call?.facingMode == 'user';
VoipPlugin get voipPlugin => widget.voipPlugin;
double? _localVideoHeight;
double? _localVideoWidth;
EdgeInsetsGeometry? _localVideoMargin;
void _playCallSound() async {
const path = 'assets/sounds/call.wav';
if (kIsWeb) {
darthtml.AudioElement()
..src = 'assets/$path'
..autoplay = true
..load();
} else if (PlatformInfos.isMobile) {
final callSoundPlayer = AudioPlayer();
await callSoundPlayer.setAsset(path);
callSoundPlayer.play();
} else {
Logs().w('Playing sound not implemented for this platform!');
}
}
@override
void initState() {
super.initState();
initialize();
_playCallSound();
}
void initialize() async {
if (voipPlugin.currentGroupCall != null) {
room = voipPlugin.currentGroupCall!.room;
proxy = GroupCallSessionState(voipPlugin.currentGroupCall!);
} else if (voipPlugin.currentCall != null) {
room = voipPlugin.currentCall!.room;
proxy = CallSessionState(voipPlugin.currentCall!);
} else {
throw Exception('No call or group call found');
}
proxy!.onStateChanged(_handleCallState);
if (!proxy!.voiceonly) {
try {
// Enable wakelock (keep screen on)
unawaited(Wakelock.enable());
} catch (_) {}
}
}
void cleanUp() {
Timer(
const Duration(seconds: 2),
() => widget.onClear.call(),
);
if (!proxy!.voiceonly) {
try {
unawaited(Wakelock.disable());
} catch (_) {}
}
}
@override
void dispose() {
super.dispose();
cleanUp();
}
void _resizeLocalVideo(Orientation orientation) {
final shortSide = min(
MediaQuery.of(context).size.width, MediaQuery.of(context).size.height);
_localVideoMargin = userMediaStreams.isNotEmpty
? const EdgeInsets.only(top: 20.0, right: 20.0)
: EdgeInsets.zero;
_localVideoWidth = userMediaStreams.isNotEmpty
? shortSide / 3
: MediaQuery.of(context).size.width;
_localVideoHeight = userMediaStreams.isNotEmpty
? shortSide / 4
: MediaQuery.of(context).size.height;
}
void _handleCallState() {
Logs().v('CallingPage::handleCallState');
if ({'connected', 'ended'}.contains(proxy!.callState.toLowerCase())) {
Vibration.vibrate(duration: 200);
}
if (mounted) {
setState(() {
if (proxy!.callState.toLowerCase() == 'ended') cleanUp();
});
}
}
void handleAnswerButtonClick() {
if (mounted) {
setState(() {
proxy?.answer();
});
}
}
void handleHangupButtonClick() {
_hangUp();
}
void _hangUp() {
setState(() {
proxy!.hangup();
});
}
void handleMicMuteButtonClick() {
setState(() {
proxy?.setMicrophoneMuted(!isMicrophoneMuted);
});
}
void handleScreenSharingButtonClick() async {
if (!proxy!.isScreensharingEnabled) {
await FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'notification_channel_id',
channelName: 'Foreground Notification',
channelDescription:
'This notification appears when the foreground service is running.',
),
);
FlutterForegroundTask.startService(
notificationTitle: 'Screen sharing',
notificationText: 'You are sharing your screen in famedly',
);
} else {
FlutterForegroundTask.stopService();
}
setState(() {
proxy?.setScreensharingEnabled(!isScreensharingEnabled);
});
}
void handleHoldButtonClick() {
setState(() {
proxy?.setRemoteOnHold(!isRemoteOnHold);
});
}
void handleVideoMuteButtonClick() {
setState(() {
proxy?.setLocalVideoMuted(!isLocalVideoMuted);
});
}
// void _switchCamera() async {
// if (call.localUserMediaStream != null) {
// await Helper.switchCamera(
// call.localUserMediaStream!.stream!.getVideoTracks()[0]);
// if (kIsMobile) {
// call.facingMode == 'user'
// ? call.facingMode = 'environment'
// : call.facingMode = 'user';
// }
// }
// setState(() {});
// }
/*
void _switchSpeaker() {
setState(() {
session.setSpeakerOn();
});
}
*/
List _buildActionButtons(bool isFloating) {
if (isFloating || proxy == null) {
return [];
}
/*
var switchSpeakerButton = FloatingActionButton(
heroTag: 'switchSpeaker',
child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off),
onPressed: _switchSpeaker,
foregroundColor: Colors.black54,
backgroundColor: Theme.of(context).backgroundColor,
);
*/
return [
FloatingActionButton(
heroTag: 'hangup',
onPressed: handleHangupButtonClick,
tooltip: 'Hangup',
backgroundColor: proxy!.callState.toLowerCase() == 'ended'
? Colors.black45
: Colors.red,
child: const Icon(Icons.call_end),
),
if (showAnswerButton)
FloatingActionButton(
heroTag: 'answer',
onPressed: handleAnswerButtonClick,
tooltip: 'Answer',
backgroundColor: Colors.green,
child: const Icon(Icons.phone),
),
if (showMicMuteButton)
FloatingActionButton(
heroTag: 'muteMic',
onPressed: handleMicMuteButtonClick,
foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white,
backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45,
child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic),
),
if (showScreenSharingButton)
FloatingActionButton(
heroTag: 'screenSharing',
onPressed: handleScreenSharingButtonClick,
foregroundColor:
isScreensharingEnabled ? Colors.black26 : Colors.white,
backgroundColor:
isScreensharingEnabled ? Colors.white : Colors.black45,
child: const Icon(Icons.desktop_mac),
),
if (showHoldButton)
FloatingActionButton(
heroTag: 'hold',
onPressed: handleHoldButtonClick,
foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white,
backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45,
child: const Icon(Icons.pause),
),
// FloatingActionButton(
// heroTag: 'switchCamera',
// onPressed: _switchCamera,
// backgroundColor: Colors.black45,
// child: const Icon(Icons.switch_camera),
// ),
if (showVideoMuteButton)
FloatingActionButton(
heroTag: 'muteCam',
onPressed: handleVideoMuteButtonClick,
foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white,
backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45,
child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam),
),
];
}
List _buildP2PView(Orientation orientation, bool isFloating) {
final stackWidgets = [];
if (proxy == null || proxy!.ended) {
return stackWidgets;
}
if (proxy!.localHold || proxy!.remoteOnHold) {
var title = '';
if (proxy!.localHold) {
title = '${proxy!.displayName} held the call.';
} else if (proxy!.remoteOnHold) {
title = 'You held the call.';
}
stackWidgets.add(Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(
Icons.pause,
size: 48.0,
color: Colors.white,
),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 24.0,
),
)
]),
));
return stackWidgets;
}
if (primaryStream != null) {
stackWidgets.add(
Center(
child: StreamView(
primaryStream!,
mainView: true,
matrixClient: voipPlugin.client,
),
),
);
}
if (isFloating || !connected) {
return stackWidgets;
}
_resizeLocalVideo(orientation);
if (userMediaStreams.isEmpty) {
return stackWidgets;
}
final secondaryStreamViews = [];
for (final stream in userMediaStreams) {
secondaryStreamViews.add(SizedBox(
width: _localVideoWidth,
height: _localVideoHeight,
child: StreamView(
stream,
matrixClient: voipPlugin.client,
),
));
secondaryStreamViews.add(const SizedBox(height: 10));
}
if (secondaryStreamViews.isNotEmpty) {
stackWidgets.add(Container(
padding: const EdgeInsets.fromLTRB(0, 20, 0, 120),
alignment: Alignment.bottomRight,
child: Container(
width: _localVideoWidth,
margin: _localVideoMargin,
child: Column(
children: secondaryStreamViews,
),
),
));
}
return stackWidgets;
}
@override
Widget build(BuildContext context) {
return PIPView(builder: (context, isFloating) {
return Scaffold(
resizeToAvoidBottomInset: !isFloating,
floatingActionButtonLocation:
FloatingActionButtonLocation.centerFloat,
floatingActionButton: SizedBox(
width: 320.0,
height: 150.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: _buildActionButtons(isFloating).reversed.toList(),
),
),
body: OrientationBuilder(
builder: (BuildContext context, Orientation orientation) {
return Container(
decoration: const BoxDecoration(
color: Colors.black87,
),
child: Stack(children: [
if (isGroupCall)
GroupCallView(
call: proxy as GroupCallSessionState,
client: voipPlugin.client)
else
..._buildP2PView(orientation, isFloating),
if (!isFloating)
Positioned(
top: 24.0,
left: 24.0,
child: IconButton(
color: Colors.red,
icon: const Icon(Icons.arrow_back),
onPressed: () {
PIPView.of(context)?.setFloating(true);
},
))
]));
}));
});
}
}