mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2025-04-21 17:37:56 +02:00
509 lines
15 KiB
Dart
509 lines
15 KiB
Dart
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<Calling> {
|
|
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<WrappedMediaStream> get screenSharingStreams =>
|
|
(proxy?.screenSharingStreams ?? []);
|
|
|
|
List<WrappedMediaStream> get userMediaStreams {
|
|
if (isGroupCall) {
|
|
return (proxy?.userMediaStreams ?? []);
|
|
}
|
|
final streams = <WrappedMediaStream>[
|
|
...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<Widget> _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<Widget> _buildP2PView(Orientation orientation, bool isFloating) {
|
|
final stackWidgets = <Widget>[];
|
|
|
|
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 = <Widget>[];
|
|
|
|
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);
|
|
},
|
|
))
|
|
]));
|
|
}));
|
|
});
|
|
}
|
|
}
|