mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2025-04-20 08:57:51 +02:00
550 lines
16 KiB
Dart
550 lines
16 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';
|
|
import 'dart:math';
|
|
|
|
import 'package:fluffychat/pages/dialer/group_call_view.dart';
|
|
import 'package:fluffychat/utils/voip/call_session_state.dart';
|
|
import 'package:fluffychat/utils/voip/call_state_proxy.dart';
|
|
import 'package:fluffychat/utils/voip/group_call_state.dart';
|
|
import 'package:fluffychat/utils/voip_plugin.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:pedantic/pedantic.dart';
|
|
import 'package:wakelock/wakelock.dart';
|
|
|
|
import 'package:fluffychat/utils/platform_infos.dart';
|
|
import 'package:fluffychat/widgets/avatar.dart';
|
|
import 'pip/pip_view.dart';
|
|
|
|
class _StreamView extends StatelessWidget {
|
|
const _StreamView(this.wrappedStream,
|
|
{Key? key, this.mainView = false, required this.matrixClient})
|
|
: super(key: key);
|
|
|
|
final WrappedMediaStream wrappedStream;
|
|
final Client matrixClient;
|
|
|
|
final bool mainView;
|
|
|
|
Uri? get avatarUrl => wrappedStream.getUser().avatarUrl;
|
|
|
|
String? get displayName => wrappedStream.displayName;
|
|
|
|
String get avatarName => wrappedStream.avatarName;
|
|
|
|
bool get isLocal => wrappedStream.isLocal();
|
|
|
|
bool get mirrored =>
|
|
wrappedStream.isLocal() &&
|
|
wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia;
|
|
|
|
bool get audioMuted => wrappedStream.audioMuted;
|
|
|
|
bool get videoMuted => wrappedStream.videoMuted;
|
|
|
|
bool get isScreenSharing =>
|
|
wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.black54,
|
|
),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: <Widget>[
|
|
if (videoMuted)
|
|
Container(
|
|
color: Colors.transparent,
|
|
),
|
|
if (!videoMuted)
|
|
RTCVideoView(
|
|
// yes, it must explicitly be casted even though I do not feel
|
|
// comfortable with it...
|
|
wrappedStream.renderer as RTCVideoRenderer,
|
|
mirror: mirrored,
|
|
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
|
),
|
|
if (videoMuted)
|
|
Positioned(
|
|
child: Avatar(
|
|
mxContent: avatarUrl,
|
|
name: displayName,
|
|
size: mainView ? 96 : 48,
|
|
client: matrixClient,
|
|
// textSize: mainView ? 36 : 24,
|
|
// matrixClient: matrixClient,
|
|
),
|
|
),
|
|
if (!isScreenSharing)
|
|
Positioned(
|
|
left: 4.0,
|
|
bottom: 4.0,
|
|
child: Icon(audioMuted ? Icons.mic_off : Icons.mic,
|
|
color: Colors.white, size: 18.0),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class Calling extends StatefulWidget {
|
|
final VoipPlugin voipPlugin;
|
|
final VoidCallback onClear;
|
|
const Calling({required this.voipPlugin, required this.onClear, Key? key})
|
|
: 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 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 ?? '') +
|
|
')';
|
|
}
|
|
|
|
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.ogg';
|
|
if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isMacOS) {
|
|
final player = AudioPlayer();
|
|
await player.setAsset(path);
|
|
player.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 (_) {}
|
|
}
|
|
}
|
|
|
|
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 (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() {
|
|
setState(() {
|
|
proxy?.setScreensharingEnabled(!isScreensharingEnabled);
|
|
});
|
|
}
|
|
|
|
void handleHoldButtonClick() {
|
|
setState(() {
|
|
proxy?.setRemoteOnHold(!isRemoteOnHold);
|
|
});
|
|
}
|
|
|
|
void handleVideoMuteButtonClick() {
|
|
setState(() {
|
|
proxy?.setLocalVideoMuted(!isLocalVideoMuted);
|
|
});
|
|
}
|
|
|
|
// Waiting for https://gitlab.com/famedly/company/frontend/famedlysdk/-/issues/301
|
|
// void _switchCamera() async {
|
|
// if (proxy!.localUserMediaStream != null) {
|
|
// await Helper.switchCamera(
|
|
// proxy!.localUserMediaStream!.stream!.getVideoTracks()[0]);
|
|
// if (PlatformInfos.isMobile) {
|
|
// proxy!.facingMode == 'user'
|
|
// ? proxy!.facingMode = 'environment'
|
|
// : proxy!.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;
|
|
}
|
|
|
|
WrappedMediaStream get screenSharing => screenSharingStreams.elementAt(0);
|
|
|
|
@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),
|
|
),
|
|
),
|
|
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)
|
|
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);
|
|
},
|
|
))
|
|
]));
|
|
}));
|
|
});
|
|
}
|
|
}
|