feat: implement WebRTC calls
Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>onboarding
parent
61749a0e6a
commit
34600ac165
@ -0,0 +1,579 @@
|
||||
/*
|
||||
* 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:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:assets_audio_player/assets_audio_player.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:universal_html/html.dart' as darthtml;
|
||||
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 VoidCallback? onClear;
|
||||
final BuildContext context;
|
||||
final String callId;
|
||||
final CallSession call;
|
||||
final Client client;
|
||||
|
||||
const Calling(
|
||||
{required this.context,
|
||||
required this.call,
|
||||
required this.client,
|
||||
required this.callId,
|
||||
this.onClear,
|
||||
Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_MyCallingPage createState() => _MyCallingPage();
|
||||
}
|
||||
|
||||
class _MyCallingPage extends State<Calling> {
|
||||
Room? get room => call?.room;
|
||||
|
||||
String get displayName => call?.displayName ?? '';
|
||||
|
||||
String get callId => widget.callId;
|
||||
|
||||
CallSession? get call => widget.call;
|
||||
|
||||
MediaStream? get localStream {
|
||||
if (call != null && call!.localUserMediaStream != null) {
|
||||
return call!.localUserMediaStream!.stream!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaStream? get remoteStream {
|
||||
if (call != null && call!.getRemoteStreams.isNotEmpty) {
|
||||
return call!.getRemoteStreams[0].stream!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get speakerOn => call?.speakerOn ?? false;
|
||||
|
||||
bool get isMicrophoneMuted => call?.isMicrophoneMuted ?? false;
|
||||
|
||||
bool get isLocalVideoMuted => call?.isLocalVideoMuted ?? false;
|
||||
|
||||
bool get isScreensharingEnabled => call?.screensharingEnabled ?? false;
|
||||
|
||||
bool get isRemoteOnHold => call?.remoteOnHold ?? false;
|
||||
|
||||
bool get voiceonly => call == null || call?.type == CallType.kVoice;
|
||||
|
||||
bool get connecting => call?.state == CallState.kConnecting;
|
||||
|
||||
bool get connected => call?.state == CallState.kConnected;
|
||||
|
||||
bool get mirrored => call?.facingMode == 'user';
|
||||
|
||||
List<WrappedMediaStream> get streams => call?.streams ?? [];
|
||||
double? _localVideoHeight;
|
||||
double? _localVideoWidth;
|
||||
EdgeInsetsGeometry? _localVideoMargin;
|
||||
CallState? _state;
|
||||
|
||||
void _playCallSound() async {
|
||||
const path = 'assets/sounds/call.wav';
|
||||
if (kIsWeb) {
|
||||
darthtml.AudioElement()
|
||||
..src = 'assets/$path'
|
||||
..autoplay = true
|
||||
..load();
|
||||
} else if (PlatformInfos.isMobile) {
|
||||
await AssetsAudioPlayer.newPlayer().open(Audio(path));
|
||||
} else {
|
||||
Logs().w('Playing sound not implemented for this platform!');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initialize();
|
||||
_playCallSound();
|
||||
}
|
||||
|
||||
void initialize() async {
|
||||
final call = this.call;
|
||||
if (call == null) return;
|
||||
|
||||
call.onCallStateChanged.listen(_handleCallState);
|
||||
call.onCallEventChanged.listen((event) {
|
||||
if (event == CallEvent.kFeedsChanged) {
|
||||
setState(() {
|
||||
call.tryRemoveStopedStreams();
|
||||
});
|
||||
} else if (event == CallEvent.kLocalHoldUnhold ||
|
||||
event == CallEvent.kRemoteHoldUnhold) {
|
||||
setState(() {});
|
||||
Logs().i(
|
||||
'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}');
|
||||
}
|
||||
});
|
||||
_state = call.state;
|
||||
|
||||
if (call.type == CallType.kVideo) {
|
||||
try {
|
||||
// Enable wakelock (keep screen on)
|
||||
unawaited(Wakelock.enable());
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void cleanUp() {
|
||||
Timer(
|
||||
const Duration(seconds: 2),
|
||||
() => widget.onClear?.call(),
|
||||
);
|
||||
if (call?.type == CallType.kVideo) {
|
||||
try {
|
||||
unawaited(Wakelock.disable());
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
call?.cleanUp.call();
|
||||
}
|
||||
|
||||
void _resizeLocalVideo(Orientation orientation) {
|
||||
final shortSide = min(
|
||||
MediaQuery.of(context).size.width, MediaQuery.of(context).size.height);
|
||||
_localVideoMargin = remoteStream != null
|
||||
? const EdgeInsets.only(top: 20.0, right: 20.0)
|
||||
: EdgeInsets.zero;
|
||||
_localVideoWidth = remoteStream != null
|
||||
? shortSide / 3
|
||||
: MediaQuery.of(context).size.width;
|
||||
_localVideoHeight = remoteStream != null
|
||||
? shortSide / 4
|
||||
: MediaQuery.of(context).size.height;
|
||||
}
|
||||
|
||||
void _handleCallState(CallState state) {
|
||||
Logs().v('CallingPage::handleCallState: ${state.toString()}');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_state = state;
|
||||
if (_state == CallState.kEnded) cleanUp();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _answerCall() {
|
||||
setState(() {
|
||||
call?.answer();
|
||||
});
|
||||
}
|
||||
|
||||
void _hangUp() {
|
||||
_playCallSound();
|
||||
setState(() {
|
||||
if (call != null && (call?.isRinging ?? false)) {
|
||||
call?.reject();
|
||||
} else {
|
||||
call?.hangup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _muteMic() {
|
||||
setState(() {
|
||||
call?.setMicrophoneMuted(!call!.isMicrophoneMuted);
|
||||
});
|
||||
}
|
||||
|
||||
void _screenSharing() {
|
||||
setState(() {
|
||||
call?.setScreensharingEnabled(!call!.screensharingEnabled);
|
||||
});
|
||||
}
|
||||
|
||||
void _remoteOnHold() {
|
||||
setState(() {
|
||||
call?.setRemoteOnHold(!call!.remoteOnHold);
|
||||
});
|
||||
}
|
||||
|
||||
void _muteCamera() {
|
||||
setState(() {
|
||||
call?.setLocalVideoMuted(!call!.isLocalVideoMuted);
|
||||
});
|
||||
}
|
||||
|
||||
void _switchCamera() async {
|
||||
if (call!.localUserMediaStream != null) {
|
||||
await Helper.switchCamera(
|
||||
call!.localUserMediaStream!.stream!.getVideoTracks()[0]);
|
||||
if (PlatformInfos.isMobile) {
|
||||
call!.facingMode == 'user'
|
||||
? call!.facingMode = 'environment'
|
||||
: call!.facingMode = 'user';
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/*
|
||||
void _switchSpeaker() {
|
||||
setState(() {
|
||||
session.setSpeakerOn();
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
List<Widget> _buildActionButtons(bool isFloating) {
|
||||
if (isFloating || call == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final switchCameraButton = FloatingActionButton(
|
||||
heroTag: 'switchCamera',
|
||||
onPressed: _switchCamera,
|
||||
backgroundColor: Colors.black45,
|
||||
child: const Icon(Icons.switch_camera),
|
||||
);
|
||||
/*
|
||||
var switchSpeakerButton = FloatingActionButton(
|
||||
heroTag: 'switchSpeaker',
|
||||
child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off),
|
||||
onPressed: _switchSpeaker,
|
||||
foregroundColor: Colors.black54,
|
||||
backgroundColor: Theme.of(context).backgroundColor,
|
||||
);
|
||||
*/
|
||||
final hangupButton = FloatingActionButton(
|
||||
heroTag: 'hangup',
|
||||
onPressed: _hangUp,
|
||||
tooltip: 'Hangup',
|
||||
backgroundColor: _state == CallState.kEnded ? Colors.black45 : Colors.red,
|
||||
child: const Icon(Icons.call_end),
|
||||
);
|
||||
|
||||
final answerButton = FloatingActionButton(
|
||||
heroTag: 'answer',
|
||||
onPressed: _answerCall,
|
||||
tooltip: 'Answer',
|
||||
backgroundColor: Colors.green,
|
||||
child: const Icon(Icons.phone),
|
||||
);
|
||||
|
||||
final muteMicButton = FloatingActionButton(
|
||||
heroTag: 'muteMic',
|
||||
onPressed: _muteMic,
|
||||
foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white,
|
||||
backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45,
|
||||
child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic),
|
||||
);
|
||||
|
||||
final screenSharingButton = FloatingActionButton(
|
||||
heroTag: 'screenSharing',
|
||||
onPressed: _screenSharing,
|
||||
foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white,
|
||||
backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45,
|
||||
child: const Icon(Icons.desktop_mac),
|
||||
);
|
||||
|
||||
final holdButton = FloatingActionButton(
|
||||
heroTag: 'hold',
|
||||
onPressed: _remoteOnHold,
|
||||
foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white,
|
||||
backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45,
|
||||
child: const Icon(Icons.pause),
|
||||
);
|
||||
|
||||
final muteCameraButton = FloatingActionButton(
|
||||
heroTag: 'muteCam',
|
||||
onPressed: _muteCamera,
|
||||
foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white,
|
||||
backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45,
|
||||
child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam),
|
||||
);
|
||||
|
||||
switch (_state) {
|
||||
case CallState.kRinging:
|
||||
case CallState.kInviteSent:
|
||||
case CallState.kCreateAnswer:
|
||||
case CallState.kConnecting:
|
||||
return call!.isOutgoing
|
||||
? <Widget>[hangupButton]
|
||||
: <Widget>[answerButton, hangupButton];
|
||||
case CallState.kConnected:
|
||||
return <Widget>[
|
||||
muteMicButton,
|
||||
//switchSpeakerButton,
|
||||
if (!voiceonly && !kIsWeb) switchCameraButton,
|
||||
if (!voiceonly) muteCameraButton,
|
||||
if (kIsWeb) screenSharingButton,
|
||||
holdButton,
|
||||
hangupButton,
|
||||
];
|
||||
case CallState.kEnded:
|
||||
return <Widget>[
|
||||
hangupButton,
|
||||
];
|
||||
case CallState.kFledgling:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kWaitLocalMedia:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kCreateOffer:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case null:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
}
|
||||
return <Widget>[];
|
||||
}
|
||||
|
||||
List<Widget> _buildContent(Orientation orientation, bool isFloating) {
|
||||
final stackWidgets = <Widget>[];
|
||||
|
||||
final call = this.call;
|
||||
if (call == null || call.callHasEnded) {
|
||||
return stackWidgets;
|
||||
}
|
||||
|
||||
if (call.localHold || call.remoteOnHold) {
|
||||
var title = '';
|
||||
if (call.localHold) {
|
||||
title = '${call.displayName} held the call.';
|
||||
} else if (call.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;
|
||||
}
|
||||
|
||||
var primaryStream = call.remoteScreenSharingStream ??
|
||||
call.localScreenSharingStream ??
|
||||
call.remoteUserMediaStream ??
|
||||
call.localUserMediaStream;
|
||||
|
||||
if (!connected) {
|
||||
primaryStream = call.localUserMediaStream;
|
||||
}
|
||||
|
||||
if (primaryStream != null) {
|
||||
stackWidgets.add(Center(
|
||||
child: _StreamView(primaryStream,
|
||||
mainView: true, matrixClient: widget.client),
|
||||
));
|
||||
}
|
||||
|
||||
if (isFloating || !connected) {
|
||||
return stackWidgets;
|
||||
}
|
||||
|
||||
_resizeLocalVideo(orientation);
|
||||
|
||||
if (call.getRemoteStreams.isEmpty) {
|
||||
return stackWidgets;
|
||||
}
|
||||
|
||||
final secondaryStreamViews = <Widget>[];
|
||||
|
||||
if (call.remoteScreenSharingStream != null) {
|
||||
final remoteUserMediaStream = call.remoteUserMediaStream;
|
||||
secondaryStreamViews.add(SizedBox(
|
||||
width: _localVideoWidth,
|
||||
height: _localVideoHeight,
|
||||
child: _StreamView(remoteUserMediaStream!, matrixClient: widget.client),
|
||||
));
|
||||
secondaryStreamViews.add(const SizedBox(height: 10));
|
||||
}
|
||||
|
||||
final localStream =
|
||||
call.localUserMediaStream ?? call.localScreenSharingStream;
|
||||
if (localStream != null && !isFloating) {
|
||||
secondaryStreamViews.add(SizedBox(
|
||||
width: _localVideoWidth,
|
||||
height: _localVideoHeight,
|
||||
child: _StreamView(localStream, matrixClient: widget.client),
|
||||
));
|
||||
secondaryStreamViews.add(const SizedBox(height: 10));
|
||||
}
|
||||
|
||||
if (call.localScreenSharingStream != null && !isFloating) {
|
||||
secondaryStreamViews.add(SizedBox(
|
||||
width: _localVideoWidth,
|
||||
height: _localVideoHeight,
|
||||
child: _StreamView(call.remoteUserMediaStream!,
|
||||
matrixClient: widget.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))),
|
||||
body: OrientationBuilder(
|
||||
builder: (BuildContext context, Orientation orientation) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black87,
|
||||
),
|
||||
child: Stack(children: [
|
||||
..._buildContent(orientation, isFloating),
|
||||
if (!isFloating)
|
||||
Positioned(
|
||||
top: 24.0,
|
||||
left: 24.0,
|
||||
child: IconButton(
|
||||
color: Colors.black45,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
PIPView.of(context)?.setFloating(true);
|
||||
},
|
||||
))
|
||||
]));
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
const defaultAnimationDuration = Duration(milliseconds: 200);
|
@ -0,0 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void dismissKeyboard(BuildContext context) {
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
}
|
@ -0,0 +1,343 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'dismiss_keyboard.dart';
|
||||
|
||||
class PIPView extends StatefulWidget {
|
||||
final PIPViewCorner initialCorner;
|
||||
final double? floatingWidth;
|
||||
final double? floatingHeight;
|
||||
final bool avoidKeyboard;
|
||||
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
bool isFloating,
|
||||
) builder;
|
||||
|
||||
const PIPView({
|
||||
Key? key,
|
||||
required this.builder,
|
||||
this.initialCorner = PIPViewCorner.topRight,
|
||||
this.floatingWidth,
|
||||
this.floatingHeight,
|
||||
this.avoidKeyboard = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
PIPViewState createState() => PIPViewState();
|
||||
|
||||
static PIPViewState? of(BuildContext context) {
|
||||
return context.findAncestorStateOfType<PIPViewState>();
|
||||
}
|
||||
}
|
||||
|
||||
class PIPViewState extends State<PIPView> with TickerProviderStateMixin {
|
||||
late AnimationController _toggleFloatingAnimationController;
|
||||
late AnimationController _dragAnimationController;
|
||||
late PIPViewCorner _corner;
|
||||
Offset _dragOffset = Offset.zero;
|
||||
bool _isDragging = false;
|
||||
bool _floating = false;
|
||||
Map<PIPViewCorner, Offset> _offsets = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_corner = widget.initialCorner;
|
||||
_toggleFloatingAnimationController = AnimationController(
|
||||
duration: defaultAnimationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
_dragAnimationController = AnimationController(
|
||||
duration: defaultAnimationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateCornersOffsets({
|
||||
required Size spaceSize,
|
||||
required Size widgetSize,
|
||||
required EdgeInsets windowPadding,
|
||||
}) {
|
||||
_offsets = _calculateOffsets(
|
||||
spaceSize: spaceSize,
|
||||
widgetSize: widgetSize,
|
||||
windowPadding: windowPadding,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isAnimating() {
|
||||
return _toggleFloatingAnimationController.isAnimating ||
|
||||
_dragAnimationController.isAnimating;
|
||||
}
|
||||
|
||||
void setFloating(bool floating) {
|
||||
if (_isAnimating()) return;
|
||||
dismissKeyboard(context);
|
||||
setState(() {
|
||||
_floating = floating;
|
||||
});
|
||||
_toggleFloatingAnimationController.forward();
|
||||
}
|
||||
|
||||
void stopFloating() {
|
||||
if (_isAnimating()) return;
|
||||
dismissKeyboard(context);
|
||||
_toggleFloatingAnimationController.reverse().whenCompleteOrCancel(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_floating = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
if (!_isDragging) return;
|
||||
setState(() {
|
||||
_dragOffset = _dragOffset.translate(
|
||||
details.delta.dx,
|
||||
details.delta.dy,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onPanCancel() {
|
||||
if (!_isDragging) return;
|
||||
setState(() {
|
||||
_dragAnimationController.value = 0;
|
||||
_dragOffset = Offset.zero;
|
||||
_isDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPanEnd(_) {
|
||||
if (!_isDragging) return;
|
||||
|
||||
final nearestCorner = _calculateNearestCorner(
|
||||
offset: _dragOffset,
|
||||
offsets: _offsets,
|
||||
);
|
||||
setState(() {
|
||||
_corner = nearestCorner;
|
||||
_isDragging = false;
|
||||
});
|
||||
_dragAnimationController.forward().whenCompleteOrCancel(() {
|
||||
_dragAnimationController.value = 0;
|
||||
_dragOffset = Offset.zero;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPanStart(_) {
|
||||
if (_isAnimating()) return;
|
||||
setState(() {
|
||||
_dragOffset = _offsets[_corner]!;
|
||||
_isDragging = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
var windowPadding = mediaQuery.padding;
|
||||
if (widget.avoidKeyboard) {
|
||||
windowPadding += mediaQuery.viewInsets;
|
||||
}
|
||||
final isFloating = _floating;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
var floatingWidth = widget.floatingWidth;
|
||||
var floatingHeight = widget.floatingHeight;
|
||||
if (floatingWidth == null && floatingHeight != null) {
|
||||
floatingWidth = width / height * floatingHeight;
|
||||
}
|
||||
floatingWidth ??= 100.0;
|
||||
floatingHeight ??= height / width * floatingWidth;
|
||||
|
||||
final floatingWidgetSize = Size(floatingWidth, floatingHeight);
|
||||
final fullWidgetSize = Size(width, height);
|
||||
|
||||
_updateCornersOffsets(
|
||||
spaceSize: fullWidgetSize,
|
||||
widgetSize: floatingWidgetSize,
|
||||
windowPadding: windowPadding,
|
||||
);
|
||||
|
||||
final calculatedOffset = _offsets[_corner];
|
||||
|
||||
// BoxFit.cover
|
||||
final widthRatio = floatingWidth / width;
|
||||
final heightRatio = floatingHeight / height;
|
||||
final scaledDownScale = widthRatio > heightRatio
|
||||
? floatingWidgetSize.width / fullWidgetSize.width
|
||||
: floatingWidgetSize.height / fullWidgetSize.height;
|
||||
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_toggleFloatingAnimationController,
|
||||
_dragAnimationController,
|
||||
]),
|
||||
builder: (context, child) {
|
||||
final animationCurve = CurveTween(
|
||||
curve: Curves.easeInOutQuad,
|
||||
);
|
||||
final dragAnimationValue = animationCurve.transform(
|
||||
_dragAnimationController.value,
|
||||
);
|
||||
final toggleFloatingAnimationValue = animationCurve.transform(
|
||||
_toggleFloatingAnimationController.value,
|
||||
);
|
||||
|
||||
final floatingOffset = _isDragging
|
||||
? _dragOffset
|
||||
: Tween<Offset>(
|
||||
begin: _dragOffset,
|
||||
end: calculatedOffset,
|
||||
).transform(_dragAnimationController.isAnimating
|
||||
? dragAnimationValue
|
||||
: toggleFloatingAnimationValue);
|
||||
final borderRadius = Tween<double>(
|
||||
begin: 0,
|
||||
end: 10,
|
||||
).transform(toggleFloatingAnimationValue);
|
||||
final width = Tween<double>(
|
||||
begin: fullWidgetSize.width,
|
||||
end: floatingWidgetSize.width,
|
||||
).transform(toggleFloatingAnimationValue);
|
||||
final height = Tween<double>(
|
||||
begin: fullWidgetSize.height,
|
||||
end: floatingWidgetSize.height,
|
||||
).transform(toggleFloatingAnimationValue);
|
||||
final scale = Tween<double>(
|
||||
begin: 1,
|
||||
end: scaledDownScale,
|
||||
).transform(toggleFloatingAnimationValue);
|
||||
return Positioned(
|
||||
left: floatingOffset.dx,
|
||||
top: floatingOffset.dy,
|
||||
child: GestureDetector(
|
||||
onPanStart: isFloating ? _onPanStart : null,
|
||||
onPanUpdate: isFloating ? _onPanUpdate : null,
|
||||
onPanCancel: isFloating ? _onPanCancel : null,
|
||||
onPanEnd: isFloating ? _onPanEnd : null,
|
||||
onTap: isFloating ? stopFloating : null,
|
||||
child: Material(
|
||||
elevation: 10,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: OverflowBox(
|
||||
maxHeight: fullWidgetSize.height,
|
||||
maxWidth: fullWidgetSize.width,
|
||||
child: IgnorePointer(
|
||||
ignoring: isFloating,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) => widget.builder(context, isFloating),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum PIPViewCorner {
|
||||
topLeft,
|
||||
topRight,
|
||||
bottomLeft,
|
||||
bottomRight,
|
||||
}
|
||||
|
||||
class _CornerDistance {
|
||||
final PIPViewCorner corner;
|
||||
final double distance;
|
||||
|
||||
_CornerDistance({
|
||||
required this.corner,
|
||||
required this.distance,
|
||||
});
|
||||
}
|
||||
|
||||
PIPViewCorner _calculateNearestCorner({
|
||||
required Offset offset,
|
||||
required Map<PIPViewCorner, Offset> offsets,
|
||||
}) {
|
||||
_CornerDistance calculateDistance(PIPViewCorner corner) {
|
||||
final distance = offsets[corner]!
|
||||
.translate(
|
||||
-offset.dx,
|
||||
-offset.dy,
|
||||
)
|
||||
.distanceSquared;
|
||||
return _CornerDistance(
|
||||
corner: corner,
|
||||
distance: distance,
|
||||
);
|
||||
}
|
||||
|
||||
final distances = PIPViewCorner.values.map(calculateDistance).toList();
|
||||
|
||||
distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance));
|
||||
|
||||
return distances.first.corner;
|
||||
}
|
||||
|
||||
Map<PIPViewCorner, Offset> _calculateOffsets({
|
||||
required Size spaceSize,
|
||||
required Size widgetSize,
|
||||
required EdgeInsets windowPadding,
|
||||
}) {
|
||||
Offset getOffsetForCorner(PIPViewCorner corner) {
|
||||
const spacing = 16;
|
||||
final left = spacing + windowPadding.left;
|
||||
final top = spacing + windowPadding.top;
|
||||
final right =
|
||||
spaceSize.width - widgetSize.width - windowPadding.right - spacing;
|
||||
final bottom =
|
||||
spaceSize.height - widgetSize.height - windowPadding.bottom - spacing;
|
||||
|
||||
switch (corner) {
|
||||
case PIPViewCorner.topLeft:
|
||||
return Offset(left, top);
|
||||
case PIPViewCorner.topRight:
|
||||
return Offset(right, top);
|
||||
case PIPViewCorner.bottomLeft:
|
||||
return Offset(left, bottom);
|
||||
case PIPViewCorner.bottomRight:
|
||||
return Offset(right, bottom);
|
||||
default:
|
||||
throw Exception('Not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
const corners = PIPViewCorner.values;
|
||||
final offsets = <PIPViewCorner, Offset>{};
|
||||
for (final corner in corners) {
|
||||
offsets[corner] = getOffsetForCorner(corner);
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}
|
@ -0,0 +1,312 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:callkeep/callkeep.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:fluffychat/utils/voip_plugin.dart';
|
||||
|
||||
class CallKeeper {
|
||||
CallKeeper(this.callKeepManager, this.uuid, this.number, this.call) {
|
||||
call?.onCallStateChanged.listen(_handleCallState);
|
||||
}
|
||||
|
||||
CallKeepManager callKeepManager;
|
||||
String number;
|
||||
String uuid;
|
||||
bool held = false;
|
||||
bool muted = false;
|
||||
bool connected = false;
|
||||
CallSession? call;
|
||||
|
||||
void _handleCallState(CallState state) {
|
||||
Logs().v('CallKeepManager::handleCallState: ${state.toString()}');
|
||||
switch (state) {
|
||||
case CallState.kConnecting:
|
||||
break;
|
||||
case CallState.kConnected:
|
||||
if (!connected) {
|
||||
callKeepManager.answer(uuid);
|
||||
} else {
|
||||
callKeepManager.setMutedCall(uuid, false);
|
||||
callKeepManager.setOnHold(uuid, false);
|
||||
}
|
||||
break;
|
||||
case CallState.kEnded:
|
||||
callKeepManager.hangup(uuid);
|
||||
break;
|
||||
/* TODO:
|
||||
case CallState.kMuted:
|
||||
callKeepManager.setMutedCall(uuid, true);
|
||||
break;
|
||||
case CallState.kHeld:
|
||||
callKeepManager.setOnHold(uuid, true);
|
||||
break;
|
||||
*/
|
||||
case CallState.kFledgling:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kInviteSent:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kWaitLocalMedia:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kCreateOffer:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kCreateAnswer:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case CallState.kRinging:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CallKeepManager {
|
||||
factory CallKeepManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
CallKeepManager._internal() {
|
||||
_callKeep = FlutterCallkeep();
|
||||
}
|
||||
|
||||
static final CallKeepManager _instance = CallKeepManager._internal();
|
||||
|
||||
late FlutterCallkeep _callKeep;
|
||||
VoipPlugin? _voipPlugin;
|
||||
Map<String, CallKeeper> calls = <String, CallKeeper>{};
|
||||
|
||||
String newUUID() => const Uuid().v4();
|
||||
|
||||
String get appName => 'Famedly';
|
||||
|
||||
Map<String, dynamic> get alertOptions => <String, dynamic>{
|
||||
'alertTitle': 'Permissions required',
|
||||
'alertDescription': '$appName needs to access your phone accounts!',
|
||||
'cancelButton': 'Cancel',
|
||||
'okButton': 'ok',
|
||||
// Required to get audio in background when using Android 11
|
||||
'foregroundService': {
|
||||
'channelId': 'com.famedly.talk',
|
||||
'channelName': 'Foreground service for my app',
|
||||
'notificationTitle': '$appName is running on background',
|
||||
'notificationIcon': 'mipmap/ic_notification_launcher',
|
||||
},
|
||||
};
|
||||
|
||||
void setVoipPlugin(VoipPlugin plugin) {
|
||||
if (kIsWeb) {
|
||||
throw 'Not support callkeep for flutter web';
|
||||
}
|
||||
_voipPlugin = plugin;
|
||||
_voipPlugin!.onIncomingCall = (CallSession call) async {
|
||||
await _callKeep.setup(
|
||||
null,
|
||||
<String, dynamic>{
|
||||
'ios': <String, dynamic>{
|
||||
'appName': appName,
|
||||
},
|
||||
'android': alertOptions,
|
||||
},
|
||||
backgroundMode: true);
|
||||
|
||||
await displayIncomingCall(call);
|
||||
|
||||
call.onCallStateChanged.listen((state) {
|
||||
if (state == CallState.kEnded) {
|
||||
_callKeep.endAllCalls();
|
||||
}
|
||||
});
|
||||
call.onCallEventChanged.listen((event) {
|
||||
if (event == CallEvent.kLocalHoldUnhold) {
|
||||
Logs().i(
|
||||
'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
void removeCall(String callUUID) {
|
||||
calls.remove(callUUID);
|
||||
}
|
||||
|
||||
void addCall(String callUUID, CallKeeper callKeeper) {
|
||||
calls[callUUID] = callKeeper;
|
||||
}
|
||||
|
||||
String findCallUUID(String number) {
|
||||
var uuid = '';
|
||||
calls.forEach((String key, CallKeeper item) {
|
||||
if (item.number == number) {
|
||||
uuid = key;
|
||||
return;
|
||||
}
|
||||
});
|
||||
return uuid;
|
||||
}
|
||||
|
||||
void setCallHeld(String callUUID, bool held) {
|
||||
calls[callUUID]!.held = held;
|
||||
}
|
||||
|
||||
void setCallMuted(String callUUID, bool muted) {
|
||||
calls[callUUID]!.muted = muted;
|
||||
}
|
||||
|
||||
void didDisplayIncomingCall(CallKeepDidDisplayIncomingCall event) {
|
||||
final callUUID = event.callUUID;
|
||||
final number = event.handle;
|
||||
Logs().v('[displayIncomingCall] $callUUID number: $number');
|
||||
addCall(callUUID!, CallKeeper(this, callUUID, number!, null));
|
||||
}
|
||||
|
||||
void onPushKitToken(CallKeepPushKitToken event) {
|
||||
Logs().v('[onPushKitToken] token => ${event.token}');
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
_callKeep.on(CallKeepPerformAnswerCallAction(), answerCall);
|
||||
_callKeep.on(CallKeepDidPerformDTMFAction(), didPerformDTMFAction);
|
||||
_callKeep.on(
|
||||
CallKeepDidReceiveStartCallAction(), didReceiveStartCallAction);
|
||||
_callKeep.on(CallKeepDidToggleHoldAction(), didToggleHoldCallAction);
|
||||
_callKeep.on(
|
||||
CallKeepDidPerformSetMutedCallAction(), didPerformSetMutedCallAction);
|
||||
_callKeep.on(CallKeepPerformEndCallAction(), endCall);
|
||||
_callKeep.on(CallKeepPushKitToken(), onPushKitToken);
|
||||
_callKeep.on(CallKeepDidDisplayIncomingCall(), didDisplayIncomingCall);
|
||||
}
|
||||
|
||||
Future<void> hangup(String callUUID) async {
|
||||
await _callKeep.endCall(callUUID);
|
||||
removeCall(callUUID);
|
||||
}
|
||||
|
||||
Future<void> reject(String callUUID) async {
|
||||
await _callKeep.rejectCall(callUUID);
|
||||
}
|
||||
|
||||
Future<void> answer(String callUUID) async {
|
||||
final keeper = calls[callUUID];
|
||||
if (!keeper!.connected) {
|
||||
await _callKeep.answerIncomingCall(callUUID);
|
||||
keeper.connected = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setOnHold(String callUUID, bool held) async {
|
||||
await _callKeep.setOnHold(callUUID, held);
|
||||
setCallHeld(callUUID, held);
|
||||
}
|
||||
|
||||
Future<void> setMutedCall(String callUUID, bool muted) async {
|
||||
await _callKeep.setMutedCall(callUUID, muted);
|
||||
setCallMuted(callUUID, muted);
|
||||
}
|
||||
|
||||
Future<void> updateDisplay(String callUUID) async {
|
||||
final number = calls[callUUID]!.number;
|
||||
// Workaround because Android doesn't display well displayName, se we have to switch ...
|
||||
if (isIOS) {
|
||||
await _callKeep.updateDisplay(callUUID,
|
||||
displayName: 'New Name', handle: number);
|
||||
} else {
|
||||
await _callKeep.updateDisplay(callUUID,
|
||||
displayName: number, handle: 'New Name');
|
||||
}
|
||||
}
|
||||
|
||||
Future<CallKeeper> displayIncomingCall(CallSession call) async {
|
||||
final callUUID = newUUID();
|
||||
final callKeeper = CallKeeper(this, callUUID, call.displayName!, call);
|
||||
addCall(callUUID, callKeeper);
|
||||
await _callKeep.displayIncomingCall(callUUID, call.displayName!,
|
||||
handleType: 'number', hasVideo: call.type == CallType.kVideo);
|
||||
return callKeeper;
|
||||
}
|
||||
|
||||
Future<void> checkoutPhoneAccountSetting(BuildContext context) async {
|
||||
await _callKeep.setup(context, <String, dynamic>{
|
||||
'ios': <String, dynamic>{
|
||||
'appName': appName,
|
||||
},
|
||||
'android': alertOptions,
|
||||
});
|
||||
final hasPhoneAccount = await _callKeep.hasPhoneAccount();
|
||||
if (!hasPhoneAccount) {
|
||||
await _callKeep.hasDefaultPhoneAccount(context, alertOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// CallActions.
|
||||
Future<void> answerCall(CallKeepPerformAnswerCallAction event) async {
|
||||
final callUUID = event.callUUID;
|
||||
final keeper = calls[event.callUUID]!;
|
||||
if (!keeper.connected) {
|
||||
// Answer Call
|
||||
keeper.call!.answer();
|
||||
keeper.connected = true;
|
||||
}
|
||||
Timer(const Duration(seconds: 1), () {
|
||||
_callKeep.setCurrentCallActive(callUUID!);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> endCall(CallKeepPerformEndCallAction event) async {
|
||||
final keeper = calls[event.callUUID];
|
||||
keeper?.call?.hangup();
|
||||
removeCall(event.callUUID!);
|
||||
}
|
||||
|
||||
Future<void> didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async {
|
||||
final keeper = calls[event.callUUID]!;
|
||||
keeper.call?.sendDTMF(event.digits!);
|
||||
}
|
||||
|
||||
Future<void> didReceiveStartCallAction(
|
||||
CallKeepDidReceiveStartCallAction event) async {
|
||||
if (event.handle == null) {
|
||||
// @TODO: sometime we receive `didReceiveStartCallAction` with handle` undefined`
|
||||
return;
|
||||
}
|
||||
final callUUID = event.callUUID ?? newUUID();
|
||||
if (event.callUUID == null) {
|
||||
final call =
|
||||
await _voipPlugin!.voip.inviteToCall(event.handle!, CallType.kVideo);
|
||||
addCall(callUUID, CallKeeper(this, callUUID, call.displayName!, call));
|
||||
}
|
||||
await _callKeep.startCall(callUUID, event.handle!, event.handle!);
|
||||
Timer(const Duration(seconds: 1), () {
|
||||
_callKeep.setCurrentCallActive(callUUID);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> didPerformSetMutedCallAction(
|
||||
CallKeepDidPerformSetMutedCallAction event) async {
|
||||
final keeper = calls[event.callUUID]!;
|
||||
if (event.muted ?? false) {
|
||||
keeper.call?.setMicrophoneMuted(true);
|
||||
} else {
|
||||
keeper.call?.setMicrophoneMuted(false);
|
||||
}
|
||||
setCallMuted(event.callUUID!, event.muted!);
|
||||
}
|
||||
|
||||
Future<void> didToggleHoldCallAction(
|
||||
CallKeepDidToggleHoldAction event) async {
|
||||
final keeper = calls[event.callUUID]!;
|
||||
if (event.hold ?? false) {
|
||||
keeper.call?.setRemoteOnHold(true);
|
||||
} else {
|
||||
keeper.call?.setRemoteOnHold(false);
|
||||
}
|
||||
setCallHeld(event.callUUID!, event.hold!);
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';
|
||||
|
||||
class UserMediaManager {
|
||||
factory UserMediaManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
UserMediaManager._internal();
|
||||
|
||||
static final UserMediaManager _instance = UserMediaManager._internal();
|
||||
|
||||
Future<void> startRingingTone() {
|
||||
if (kIsWeb) {
|
||||
throw 'Platform [web] not supported';
|
||||
}
|
||||
return FlutterRingtonePlayer.playRingtone(volume: 80);
|
||||
}
|
||||
|
||||
Future<void> stopRingingTone() {
|
||||
if (kIsWeb) {
|
||||
throw 'Platform [web] not supported';
|
||||
}
|
||||
return FlutterRingtonePlayer.stop();
|
||||
}
|
||||
}
|
@ -0,0 +1,136 @@
|
||||
import 'dart:core';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
|
||||
import 'package:fluffychat/pages/dialer/dialer.dart';
|
||||
import '../../utils/voip/user_media_manager.dart';
|
||||
|
||||
class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate {
|
||||
VoipPlugin({required this.client, required this.context}) {
|
||||
voip = VoIP(client, this);
|
||||
Connectivity()
|
||||
.onConnectivityChanged
|
||||
.listen(_handleNetworkChanged)
|
||||
.onError((e) => _currentConnectivity = ConnectivityResult.none);
|
||||
Connectivity()
|
||||
.checkConnectivity()
|
||||
.then((result) => _currentConnectivity = result)
|
||||
.catchError((e) => _currentConnectivity = ConnectivityResult.none);
|
||||
if (!kIsWeb) {
|
||||
final wb = WidgetsBinding.instance;
|
||||
wb?.addObserver(this);
|
||||
if (wb != null) {
|
||||
didChangeAppLifecycleState(wb.lifecycleState!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Client client;
|
||||
bool background = false;
|
||||
bool speakerOn = false;
|
||||
late VoIP voip;
|
||||
ConnectivityResult? _currentConnectivity;
|
||||
ValueChanged<CallSession>? onIncomingCall;
|
||||
OverlayEntry? overlayEntry;
|
||||
|
||||
final BuildContext context;
|
||||
|
||||
void _handleNetworkChanged(ConnectivityResult result) async {
|
||||
/// Got a new connectivity status!
|
||||
if (_currentConnectivity != result) {
|
||||
voip.calls.forEach((_, sess) {
|
||||
sess.restartIce();
|
||||
});
|
||||
}
|
||||
_currentConnectivity = result;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
Logs().v('AppLifecycleState = $state');
|
||||
background = !(state != AppLifecycleState.detached &&
|
||||
state != AppLifecycleState.paused);
|
||||
}
|
||||
|
||||
void addCallingOverlay(
|
||||
BuildContext context, String callId, CallSession call) {
|
||||
if (overlayEntry != null) {
|
||||
Logs().w('[VOIP] addCallingOverlay: The call session already exists?');
|
||||
overlayEntry?.remove();
|
||||
}
|
||||
overlayEntry = OverlayEntry(
|
||||
builder: (_) => Calling(
|
||||
context: context,
|
||||
client: client,
|
||||
callId: callId,
|
||||
call: call,
|
||||
onClear: () {
|
||||
overlayEntry?.remove();
|
||||
overlayEntry = null;
|
||||
}),
|
||||
);
|
||||
Overlay.of(context)!.insert(overlayEntry!);
|
||||
}
|
||||
|
||||
@override
|
||||
MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices;
|
||||
|
||||
@override
|
||||
bool get isBackgroud => background;
|
||||
|
||||
@override
|
||||
bool get isWeb => kIsWeb;
|
||||
|
||||
@override
|
||||
Future<RTCPeerConnection> createPeerConnection(
|
||||
Map<String, dynamic> configuration,
|
||||
[Map<String, dynamic> constraints = const {}]) =>
|
||||
webrtc_impl.createPeerConnection(configuration, constraints);
|
||||
|
||||
@override
|
||||
VideoRenderer createRenderer() {
|
||||
return webrtc_impl.RTCVideoRenderer();
|
||||
}
|
||||
|
||||
@override
|
||||
void playRingtone() async {
|
||||
if (!background) {
|
||||
try {
|
||||
await UserMediaManager().startRingingTone();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void stopRingtone() async {
|
||||
if (!background) {
|
||||
try {
|
||||
await UserMediaManager().stopRingingTone();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleNewCall(CallSession call) async {
|
||||
/// Popup CallingPage for incoming call.
|
||||
if (!background) {
|
||||
addCallingOverlay(context, call.callId, call);
|
||||
} else {
|
||||
onIncomingCall?.call(call);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCallEnded(CallSession session) async {
|
||||
if (overlayEntry != null) {
|
||||
overlayEntry?.remove();
|
||||
overlayEntry = null;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
flutter build apk --debug -v
|
||||
flutter build apk --debug
|
||||
|
Loading…
Reference in New Issue