mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2025-10-17 04:57:26 +02:00
feat: group calls
This commit is contained in:
parent
0c94ffcd0b
commit
1f505bf58c
4
.gitignore
vendored
4
.gitignore
vendored
@ -10,11 +10,10 @@
|
|||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
lib/generated_plugin_registrant.dart
|
|
||||||
prime
|
prime
|
||||||
|
|
||||||
# libolm package
|
# libolm package
|
||||||
/assets/js/package/*
|
assets/assets/js/package/*
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
@ -38,7 +37,6 @@ prime
|
|||||||
/build/
|
/build/
|
||||||
|
|
||||||
# Web related
|
# Web related
|
||||||
lib/generated_plugin_registrant.dart
|
|
||||||
docs/build/
|
docs/build/
|
||||||
docs/.jekyll-cache/
|
docs/.jekyll-cache/
|
||||||
docs/_site/
|
docs/_site/
|
||||||
|
@ -2949,5 +2949,6 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"number": {}
|
"number": {}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"groupCall": "Group call"
|
||||||
}
|
}
|
||||||
|
@ -997,15 +997,22 @@ class ChatController extends State<Chat> {
|
|||||||
message: L10n.of(context)!.videoCallsBetaWarning,
|
message: L10n.of(context)!.videoCallsBetaWarning,
|
||||||
cancelLabel: L10n.of(context)!.cancel,
|
cancelLabel: L10n.of(context)!.cancel,
|
||||||
actions: [
|
actions: [
|
||||||
|
if (room!.isDirectChat)
|
||||||
|
SheetAction(
|
||||||
|
label: L10n.of(context)!.voiceCall,
|
||||||
|
icon: Icons.phone_outlined,
|
||||||
|
key: CallType.kVoice,
|
||||||
|
),
|
||||||
|
if (room!.isDirectChat)
|
||||||
|
SheetAction(
|
||||||
|
label: L10n.of(context)!.videoCall,
|
||||||
|
icon: Icons.video_call_outlined,
|
||||||
|
key: CallType.kVideo,
|
||||||
|
),
|
||||||
SheetAction(
|
SheetAction(
|
||||||
label: L10n.of(context)!.voiceCall,
|
label: L10n.of(context)!.groupCall,
|
||||||
icon: Icons.phone_outlined,
|
icon: Icons.people,
|
||||||
key: CallType.kVoice,
|
key: CallType.kGroup,
|
||||||
),
|
|
||||||
SheetAction(
|
|
||||||
label: L10n.of(context)!.videoCall,
|
|
||||||
icon: Icons.video_call_outlined,
|
|
||||||
key: CallType.kVideo,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -1017,11 +1024,20 @@ class ChatController extends State<Chat> {
|
|||||||
Matrix.of(context).voipPlugin!.voip.requestTurnServerCredentials());
|
Matrix.of(context).voipPlugin!.voip.requestTurnServerCredentials());
|
||||||
if (success.result != null) {
|
if (success.result != null) {
|
||||||
final voipPlugin = Matrix.of(context).voipPlugin;
|
final voipPlugin = Matrix.of(context).voipPlugin;
|
||||||
await voipPlugin!.voip.inviteToCall(room!.id, callType).catchError((e) {
|
if ({CallType.kVideo, CallType.kVoice}.contains(callType)) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await voipPlugin!.voip.inviteToCall(room!.id, callType).catchError((e) {
|
||||||
SnackBar(content: Text((e as Object).toLocalizedString(context))),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
);
|
SnackBar(
|
||||||
});
|
content: Text(e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
final groupCall =
|
||||||
|
await voipPlugin!.voip.fetchOrCreateGroupCall(room!.id);
|
||||||
|
groupCall?.enter();
|
||||||
|
Logs().d('Group call should be enter now');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await showOkAlertDialog(
|
await showOkAlertDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
@ -110,8 +110,7 @@ class ChatView extends StatelessWidget {
|
|||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
if (Matrix.of(context).voipPlugin != null &&
|
if (Matrix.of(context).voipPlugin != null)
|
||||||
controller.room!.isDirectChat)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: controller.onPhoneButtonTap,
|
onPressed: controller.onPhoneButtonTap,
|
||||||
icon: const Icon(Icons.call_outlined),
|
icon: const Icon(Icons.call_outlined),
|
||||||
|
@ -22,11 +22,11 @@ import 'package:fluffychat/utils/platform_infos.dart';
|
|||||||
import '../../../utils/account_bundles.dart';
|
import '../../../utils/account_bundles.dart';
|
||||||
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
|
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
|
||||||
import '../../utils/url_launcher.dart';
|
import '../../utils/url_launcher.dart';
|
||||||
import '../../utils/voip/callkeep_manager.dart';
|
|
||||||
import '../../widgets/fluffy_chat_app.dart';
|
import '../../widgets/fluffy_chat_app.dart';
|
||||||
import '../../widgets/matrix.dart';
|
import '../../widgets/matrix.dart';
|
||||||
import '../bootstrap/bootstrap_dialog.dart';
|
import '../bootstrap/bootstrap_dialog.dart';
|
||||||
import '../settings_account/settings_account.dart';
|
import '../settings_account/settings_account.dart';
|
||||||
|
import '../voip/utils/callkeep_manager.dart';
|
||||||
|
|
||||||
import 'package:fluffychat/utils/tor_stub.dart'
|
import 'package:fluffychat/utils/tor_stub.dart'
|
||||||
if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart';
|
if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart';
|
||||||
|
@ -1,600 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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:flutter_foreground_task/flutter_foreground_task.dart';
|
|
||||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
||||||
import 'package:just_audio/just_audio.dart';
|
|
||||||
import 'package:matrix/matrix.dart';
|
|
||||||
import 'package:vibration/vibration.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 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.localUserMediaStream != null) {
|
|
||||||
return call.localUserMediaStream!.stream!;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaStream? get remoteStream {
|
|
||||||
if (call.getRemoteStreams.isNotEmpty) {
|
|
||||||
return call.getRemoteStreams[0].stream!;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get speakerOn => call.speakerOn;
|
|
||||||
|
|
||||||
bool get isMicrophoneMuted => call.isMicrophoneMuted;
|
|
||||||
|
|
||||||
bool get isLocalVideoMuted => call.isLocalVideoMuted;
|
|
||||||
|
|
||||||
bool get isScreensharingEnabled => call.screensharingEnabled;
|
|
||||||
|
|
||||||
bool get isRemoteOnHold => call.remoteOnHold;
|
|
||||||
|
|
||||||
bool get voiceonly => 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.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 {
|
|
||||||
final call = this.call;
|
|
||||||
call.onCallStateChanged.stream.listen(_handleCallState);
|
|
||||||
call.onCallEventChanged.stream.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 ({CallState.kConnected, CallState.kEnded}.contains(state)) {
|
|
||||||
try {
|
|
||||||
Vibration.vibrate(duration: 200);
|
|
||||||
} catch (e) {
|
|
||||||
Logs().e('[Dialer] could not vibrate for call updates');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_state = state;
|
|
||||||
if (_state == CallState.kEnded) cleanUp();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _answerCall() {
|
|
||||||
setState(() {
|
|
||||||
call.answer();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _hangUp() {
|
|
||||||
setState(() {
|
|
||||||
if (call.isRinging) {
|
|
||||||
call.reject();
|
|
||||||
} else {
|
|
||||||
call.hangup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _muteMic() {
|
|
||||||
setState(() {
|
|
||||||
call.setMicrophoneMuted(!call.isMicrophoneMuted);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _screenSharing() async {
|
|
||||||
if (PlatformInfos.isAndroid) {
|
|
||||||
if (!call.screensharingEnabled) {
|
|
||||||
await FlutterForegroundTask.init(
|
|
||||||
androidNotificationOptions: AndroidNotificationOptions(
|
|
||||||
channelId: 'notification_channel_id',
|
|
||||||
channelName: 'Foreground Notification',
|
|
||||||
channelDescription: L10n.of(context)!.foregroundServiceRunning,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
FlutterForegroundTask.startService(
|
|
||||||
notificationTitle: L10n.of(context)!.screenSharingTitle,
|
|
||||||
notificationText: L10n.of(context)!.screenSharingDetail);
|
|
||||||
} else {
|
|
||||||
FlutterForegroundTask.stopService();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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 (PlatformInfos.isMobile || PlatformInfos.isWeb)
|
|
||||||
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.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);
|
|
||||||
},
|
|
||||||
))
|
|
||||||
]));
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,8 +6,8 @@ import 'package:vrouter/vrouter.dart';
|
|||||||
|
|
||||||
import 'package:fluffychat/config/app_config.dart';
|
import 'package:fluffychat/config/app_config.dart';
|
||||||
import 'package:fluffychat/config/setting_keys.dart';
|
import 'package:fluffychat/config/setting_keys.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/utils/callkeep_manager.dart';
|
||||||
import 'package:fluffychat/utils/platform_infos.dart';
|
import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
import 'package:fluffychat/utils/voip/callkeep_manager.dart';
|
|
||||||
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
|
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
|
||||||
import 'package:fluffychat/widgets/matrix.dart';
|
import 'package:fluffychat/widgets/matrix.dart';
|
||||||
import 'package:fluffychat/widgets/settings_switch_list_tile.dart';
|
import 'package:fluffychat/widgets/settings_switch_list_tile.dart';
|
||||||
|
508
lib/pages/voip/dialer/dialer.dart
Normal file
508
lib/pages/voip/dialer/dialer.dart
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
]));
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
213
lib/pages/voip/group_call_view.dart
Normal file
213
lib/pages/voip/group_call_view.dart
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter_reorderable_grid_view/entities/order_update_entity.dart';
|
||||||
|
import 'package:flutter_reorderable_grid_view/widgets/widgets.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
import 'package:fluffychat/pages/voip/utils/group_call_session_state.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/utils/stream_view.dart';
|
||||||
|
import 'dialer/dialer.dart';
|
||||||
|
|
||||||
|
class GroupCallView extends StatefulWidget {
|
||||||
|
final GroupCallSessionState call;
|
||||||
|
final Client client;
|
||||||
|
const GroupCallView({
|
||||||
|
Key? key,
|
||||||
|
required this.call,
|
||||||
|
required this.client,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GroupCallView> createState() => _GroupCallViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupCallViewState extends State<GroupCallView> {
|
||||||
|
WrappedMediaStream? get primaryStream => widget.call.primaryStream;
|
||||||
|
|
||||||
|
List<WrappedMediaStream> get screenSharingStreams =>
|
||||||
|
widget.call.screenSharingStreams;
|
||||||
|
List<WrappedMediaStream> get userMediaStreams => widget.call.userMediaStreams;
|
||||||
|
|
||||||
|
WrappedMediaStream? get primaryScreenShare =>
|
||||||
|
widget.call.screenSharingStreams.first;
|
||||||
|
|
||||||
|
void updateStreams() {
|
||||||
|
Logs().i('Group calls, updating streams');
|
||||||
|
widget.call.groupCall.onStreamAdd.stream.listen((event) {
|
||||||
|
if (event.purpose == SDPStreamMetadataPurpose.Usermedia) {
|
||||||
|
if (userMediaStreams.indexWhere((element) => element == event) == -1) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
userMediaStreams.add(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.purpose == SDPStreamMetadataPurpose.Screenshare) {
|
||||||
|
if (screenSharingStreams.indexWhere((element) => element == event) ==
|
||||||
|
-1) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
screenSharingStreams.add(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
widget.call.groupCall.onStreamRemoved.stream.listen((event) {
|
||||||
|
if (event.purpose == SDPStreamMetadataPurpose.Usermedia) {
|
||||||
|
userMediaStreams
|
||||||
|
.removeWhere((element) => element.stream!.id == event.stream!.id);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
userMediaStreams.remove(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (event.purpose == SDPStreamMetadataPurpose.Screenshare) {
|
||||||
|
screenSharingStreams
|
||||||
|
.removeWhere((element) => element.stream!.id == event.stream!.id);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
screenSharingStreams.remove(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
updateStreams();
|
||||||
|
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (screenSharingStreams.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
StreamView(
|
||||||
|
screenSharingStreams.first,
|
||||||
|
matrixClient: widget.client,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 150,
|
||||||
|
child: SizedBox(
|
||||||
|
// height: 400,
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: CallGrid(
|
||||||
|
call: widget.call,
|
||||||
|
screenSharing: true,
|
||||||
|
userMediaStreams: userMediaStreams,
|
||||||
|
client: widget.client,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No one is screen sharing, show avatars and user streams here
|
||||||
|
return Center(
|
||||||
|
child: CallGrid(
|
||||||
|
call: widget.call,
|
||||||
|
screenSharing: false,
|
||||||
|
userMediaStreams: userMediaStreams,
|
||||||
|
client: widget.client,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallGrid extends StatefulWidget {
|
||||||
|
final GroupCallSessionState call;
|
||||||
|
final Client client;
|
||||||
|
final bool screenSharing;
|
||||||
|
final List<WrappedMediaStream> userMediaStreams;
|
||||||
|
const CallGrid(
|
||||||
|
{Key? key,
|
||||||
|
required this.call,
|
||||||
|
required this.client,
|
||||||
|
required this.screenSharing,
|
||||||
|
required this.userMediaStreams})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CallGrid> createState() => _CallGridState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallGridState extends State<CallGrid> {
|
||||||
|
int axisCount() {
|
||||||
|
var orientation = MediaQuery.of(context).orientation;
|
||||||
|
if (MediaQuery.of(context).size.width >= 600) {
|
||||||
|
orientation = Orientation.landscape;
|
||||||
|
}
|
||||||
|
if (widget.screenSharing) {
|
||||||
|
return orientation == Orientation.portrait ? 4 : 6;
|
||||||
|
}
|
||||||
|
if (widget.call.groupCall.participants.length > 2 ||
|
||||||
|
MediaQuery.of(context).size.width >= 600) {
|
||||||
|
return orientation == Orientation.portrait ? 2 : 4;
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
final _gridViewKey = GlobalKey();
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final participants = widget.call.groupCall.participants;
|
||||||
|
|
||||||
|
Logs().w(participants
|
||||||
|
.map((e) => widget.userMediaStreams
|
||||||
|
.firstWhereOrNull((element) => element.userId == e.id))
|
||||||
|
.toString());
|
||||||
|
|
||||||
|
// adding a user to a call sometimes results in multiple userMediaStreams
|
||||||
|
// for the saem user, Just pick the latest one then.
|
||||||
|
final generatedChildren = participants
|
||||||
|
.map(
|
||||||
|
(participant) => widget.userMediaStreams
|
||||||
|
.lastWhereOrNull((stream) => stream.userId == participant.id),
|
||||||
|
)
|
||||||
|
.where((element) => element != null)
|
||||||
|
.map(
|
||||||
|
(userMediaStream) => StreamView(
|
||||||
|
userMediaStream!,
|
||||||
|
key: Key(userMediaStream.userId),
|
||||||
|
matrixClient: widget.client,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (widget.screenSharing &&
|
||||||
|
MediaQuery.of(context).orientation == Orientation.landscape) {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
return ReorderableBuilder(
|
||||||
|
scrollController: _scrollController,
|
||||||
|
onReorder: (List<OrderUpdateEntity> orderUpdateEntities) {
|
||||||
|
for (final orderUpdateEntity in orderUpdateEntities) {
|
||||||
|
final reorderParticipant =
|
||||||
|
participants.removeAt(orderUpdateEntity.oldIndex);
|
||||||
|
participants.insert(orderUpdateEntity.newIndex, reorderParticipant);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (children) {
|
||||||
|
return GridView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
key: _gridViewKey,
|
||||||
|
controller: _scrollController,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: axisCount(),
|
||||||
|
),
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
children: generatedChildren,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
192
lib/pages/voip/utils/call_session_state.dart
Normal file
192
lib/pages/voip/utils/call_session_state.dart
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
import 'call_state_proxy.dart';
|
||||||
|
|
||||||
|
class CallSessionState implements CallStateProxy {
|
||||||
|
final CallSession call;
|
||||||
|
Function()? callback;
|
||||||
|
CallSessionState(this.call) {
|
||||||
|
call.onCallEventChanged.stream.listen((CallEvent event) {
|
||||||
|
if (event == CallEvent.kState ||
|
||||||
|
event == CallEvent.kFeedsChanged ||
|
||||||
|
event == CallEvent.kLocalHoldUnhold ||
|
||||||
|
event == CallEvent.kRemoteHoldUnhold) {
|
||||||
|
if (event == CallEvent.kFeedsChanged) {
|
||||||
|
call.tryRemoveStopedStreams();
|
||||||
|
}
|
||||||
|
callback?.call();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
call.onCallStateChanged.stream.listen((state) {
|
||||||
|
callback?.call();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get voiceonly => call.type == CallType.kVoice;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get connecting => call.state == CallState.kConnecting;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get connected => call.state == CallState.kConnected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get ended => call.state == CallState.kEnded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isOutgoing => call.isOutgoing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get ringingPlay => call.state == CallState.kInviteSent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void answer() => call.answer();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void enter() {
|
||||||
|
// TODO: implement enter
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void hangup() {
|
||||||
|
if (call.isRinging) {
|
||||||
|
call.reject();
|
||||||
|
} else {
|
||||||
|
call.hangup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLocalVideoMuted => call.isLocalVideoMuted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isMicrophoneMuted => call.isMicrophoneMuted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isRemoteOnHold => call.remoteOnHold;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get localHold => call.localHold;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get remoteOnHold => call.remoteOnHold;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isScreensharingEnabled => call.screensharingEnabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get callOnHold => call.localHold || call.remoteOnHold;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setLocalVideoMuted(bool muted) {
|
||||||
|
call.setLocalVideoMuted(muted);
|
||||||
|
callback?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setMicrophoneMuted(bool muted) {
|
||||||
|
call.setMicrophoneMuted(muted);
|
||||||
|
// TODO(Nico): Refactor this to be more granular
|
||||||
|
callback?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setRemoteOnHold(bool onHold) {
|
||||||
|
call.setRemoteOnHold(onHold);
|
||||||
|
callback?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setScreensharingEnabled(bool enabled) {
|
||||||
|
call.setScreensharingEnabled(enabled);
|
||||||
|
callback?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get callState {
|
||||||
|
switch (call.state) {
|
||||||
|
case CallState.kCreateAnswer:
|
||||||
|
case CallState.kFledgling:
|
||||||
|
case CallState.kWaitLocalMedia:
|
||||||
|
case CallState.kCreateOffer:
|
||||||
|
break;
|
||||||
|
case CallState.kRinging:
|
||||||
|
state = 'Ringing';
|
||||||
|
break;
|
||||||
|
case CallState.kInviteSent:
|
||||||
|
state = 'Invite Sent';
|
||||||
|
break;
|
||||||
|
case CallState.kConnecting:
|
||||||
|
state = 'Connecting';
|
||||||
|
break;
|
||||||
|
case CallState.kConnected:
|
||||||
|
state = 'Connected';
|
||||||
|
break;
|
||||||
|
case CallState.kEnded:
|
||||||
|
state = 'Ended';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
String state = 'New Call';
|
||||||
|
@override
|
||||||
|
WrappedMediaStream? get localUserMediaStream => call.localUserMediaStream;
|
||||||
|
@override
|
||||||
|
WrappedMediaStream? get localScreenSharingStream =>
|
||||||
|
call.localScreenSharingStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<WrappedMediaStream> get screenSharingStreams {
|
||||||
|
final streams = <WrappedMediaStream>[];
|
||||||
|
if (connected) {
|
||||||
|
if (call.remoteScreenSharingStream != null) {
|
||||||
|
streams.add(call.remoteScreenSharingStream!);
|
||||||
|
}
|
||||||
|
if (call.localScreenSharingStream != null) {
|
||||||
|
streams.add(call.localScreenSharingStream!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<WrappedMediaStream> get userMediaStreams {
|
||||||
|
final streams = <WrappedMediaStream>[];
|
||||||
|
if (connected) {
|
||||||
|
if (call.remoteUserMediaStream != null) {
|
||||||
|
streams.add(call.remoteUserMediaStream!);
|
||||||
|
}
|
||||||
|
if (call.localUserMediaStream != null) {
|
||||||
|
streams.add(call.localUserMediaStream!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
WrappedMediaStream? get primaryStream {
|
||||||
|
if (screenSharingStreams.isNotEmpty) {
|
||||||
|
return screenSharingStreams.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMediaStreams.isNotEmpty) {
|
||||||
|
return userMediaStreams.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
return call.localUserMediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
return call.localScreenSharingStream ?? call.localUserMediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get displayName => call.displayName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onStateChanged(Function() handler) {
|
||||||
|
callback = handler;
|
||||||
|
}
|
||||||
|
}
|
45
lib/pages/voip/utils/call_state_proxy.dart
Normal file
45
lib/pages/voip/utils/call_state_proxy.dart
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
abstract class CallStateProxy {
|
||||||
|
String? get displayName;
|
||||||
|
bool get isMicrophoneMuted;
|
||||||
|
bool get isLocalVideoMuted;
|
||||||
|
bool get isScreensharingEnabled;
|
||||||
|
bool get isRemoteOnHold;
|
||||||
|
bool get localHold;
|
||||||
|
bool get remoteOnHold;
|
||||||
|
bool get voiceonly;
|
||||||
|
bool get connecting;
|
||||||
|
bool get connected;
|
||||||
|
bool get ended;
|
||||||
|
bool get callOnHold;
|
||||||
|
bool get isOutgoing;
|
||||||
|
bool get ringingPlay;
|
||||||
|
String get callState;
|
||||||
|
|
||||||
|
void answer();
|
||||||
|
|
||||||
|
void hangup();
|
||||||
|
|
||||||
|
void enter();
|
||||||
|
|
||||||
|
void setMicrophoneMuted(bool muted);
|
||||||
|
|
||||||
|
void setLocalVideoMuted(bool muted);
|
||||||
|
|
||||||
|
void setScreensharingEnabled(bool enabled);
|
||||||
|
|
||||||
|
void setRemoteOnHold(bool onHold);
|
||||||
|
|
||||||
|
WrappedMediaStream? get localUserMediaStream;
|
||||||
|
|
||||||
|
WrappedMediaStream? get localScreenSharingStream;
|
||||||
|
|
||||||
|
WrappedMediaStream? get primaryStream;
|
||||||
|
|
||||||
|
List<WrappedMediaStream> get screenSharingStreams;
|
||||||
|
|
||||||
|
List<WrappedMediaStream> get userMediaStreams;
|
||||||
|
|
||||||
|
void onStateChanged(Function() callback);
|
||||||
|
}
|
@ -8,7 +8,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
|
|||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
import 'package:fluffychat/utils/voip_plugin.dart';
|
import 'package:fluffychat/pages/voip/utils/voip_plugin.dart';
|
||||||
|
|
||||||
class CallKeeper {
|
class CallKeeper {
|
||||||
CallKeeper(this.callKeepManager, this.call) {
|
CallKeeper(this.callKeepManager, this.call) {
|
124
lib/pages/voip/utils/group_call_session_state.dart
Normal file
124
lib/pages/voip/utils/group_call_session_state.dart
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
import 'call_state_proxy.dart';
|
||||||
|
|
||||||
|
class GroupCallSessionState implements CallStateProxy {
|
||||||
|
Function()? callback;
|
||||||
|
final GroupCall groupCall;
|
||||||
|
GroupCallSessionState(this.groupCall) {
|
||||||
|
groupCall.onGroupCallEvent.stream.listen((event) {
|
||||||
|
Logs().i("onGroupCallEvent ${event.toString()}");
|
||||||
|
callback?.call();
|
||||||
|
});
|
||||||
|
groupCall.onGroupCallState.stream.listen((event) {
|
||||||
|
Logs().i("onGroupCallState ${event.toString()}");
|
||||||
|
callback?.call();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void answer() {
|
||||||
|
// TODO: implement answer
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get callOnHold => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get callState => 'New Group Call...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get connected => groupCall.state == GroupCallState.Entered;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get connecting => groupCall.state == GroupCallState.Entering;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get displayName => groupCall.displayName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get ended =>
|
||||||
|
groupCall.state == GroupCallState.Ended ||
|
||||||
|
groupCall.state == GroupCallState.LocalCallFeedUninitialized;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void enter() async {
|
||||||
|
await groupCall.initLocalStream();
|
||||||
|
groupCall.enter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void hangup() async {
|
||||||
|
groupCall.leave();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLocalVideoMuted => groupCall.isLocalVideoMuted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isMicrophoneMuted => groupCall.isMicrophoneMuted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isOutgoing => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isRemoteOnHold => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isScreensharingEnabled => groupCall.isScreensharing();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get localHold => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
WrappedMediaStream? get localScreenSharingStream =>
|
||||||
|
groupCall.localScreenshareStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
WrappedMediaStream? get localUserMediaStream =>
|
||||||
|
groupCall.localUserMediaStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onStateChanged(Function() handler) {
|
||||||
|
callback = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
WrappedMediaStream? get primaryStream => groupCall.localUserMediaStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get remoteOnHold => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get ringingPlay => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<WrappedMediaStream> get screenSharingStreams =>
|
||||||
|
groupCall.screenshareStreams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<WrappedMediaStream> get userMediaStreams => groupCall.userMediaStreams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setLocalVideoMuted(bool muted) {
|
||||||
|
groupCall.setLocalVideoMuted(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setMicrophoneMuted(bool muted) {
|
||||||
|
groupCall.setMicrophoneMuted(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setRemoteOnHold(bool onHold) {
|
||||||
|
// TODO: implement setRemoteOnHold
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setScreensharingEnabled(bool enabled) {
|
||||||
|
groupCall.setScreensharingEnabled(enabled, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get voiceonly => false;
|
||||||
|
}
|
106
lib/pages/voip/utils/stream_view.dart
Normal file
106
lib/pages/voip/utils/stream_view.dart
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
import 'package:fluffychat/widgets/avatar.dart';
|
||||||
|
|
||||||
|
class StreamView extends StatefulWidget {
|
||||||
|
const StreamView(this.wrappedStream,
|
||||||
|
{Key? key, this.mainView = false, required this.matrixClient})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
final WrappedMediaStream wrappedStream;
|
||||||
|
final Client matrixClient;
|
||||||
|
|
||||||
|
final bool mainView;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StreamView> createState() => _StreamViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StreamViewState extends State<StreamView> {
|
||||||
|
Uri? get avatarUrl => widget.wrappedStream.getUser().avatarUrl;
|
||||||
|
|
||||||
|
String? get displayName => widget.wrappedStream.displayName;
|
||||||
|
|
||||||
|
String get avatarName => widget.wrappedStream.avatarName;
|
||||||
|
|
||||||
|
bool get isLocal => widget.wrappedStream.isLocal();
|
||||||
|
|
||||||
|
bool get mirrored =>
|
||||||
|
widget.wrappedStream.isLocal() &&
|
||||||
|
widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia;
|
||||||
|
|
||||||
|
late bool audioMuted;
|
||||||
|
late bool videoMuted;
|
||||||
|
|
||||||
|
bool get isScreenSharing =>
|
||||||
|
widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
audioMuted = widget.wrappedStream.audioMuted;
|
||||||
|
videoMuted = widget.wrappedStream.videoMuted;
|
||||||
|
widget.wrappedStream.onMuteStateChanged.stream.listen((stream) {
|
||||||
|
if (stream.audioMuted != audioMuted) {
|
||||||
|
setState(() {
|
||||||
|
audioMuted = stream.audioMuted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (stream.videoMuted != videoMuted) {
|
||||||
|
setState(() {
|
||||||
|
videoMuted = stream.videoMuted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(widget.wrappedStream.renderer as RTCVideoRenderer,
|
||||||
|
mirror: mirrored,
|
||||||
|
objectFit:
|
||||||
|
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain),
|
||||||
|
if (videoMuted)
|
||||||
|
Positioned(
|
||||||
|
child: Avatar(
|
||||||
|
mxContent: avatarUrl,
|
||||||
|
name: displayName ?? '',
|
||||||
|
size: widget.mainView ? 96 : 48,
|
||||||
|
fontSize: widget.mainView ? 36 : 24,
|
||||||
|
client: widget.matrixClient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isScreenSharing)
|
||||||
|
Positioned(
|
||||||
|
left: 4.0,
|
||||||
|
bottom: 4.0,
|
||||||
|
child: Icon(audioMuted ? Icons.mic_off : Icons.mic,
|
||||||
|
color: Colors.white, size: 18.0),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 4.0,
|
||||||
|
bottom: 4.0,
|
||||||
|
child: Text(
|
||||||
|
widget.wrappedStream.displayName.toString(),
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
341
lib/pages/voip/utils/voip_plugin.dart
Normal file
341
lib/pages/voip/utils/voip_plugin.dart
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
import 'dart:core';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl;
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator;
|
||||||
|
|
||||||
|
import 'package:fluffychat/pages/chat_list/chat_list.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/dialer/dialer.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/utils/callkeep_manager.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/utils/user_media_manager.dart';
|
||||||
|
import 'package:fluffychat/utils/famedlysdk_store.dart';
|
||||||
|
import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
|
import 'package:fluffychat/widgets/fluffy_chat_app.dart';
|
||||||
|
|
||||||
|
enum VoipType { kVoice, kVideo, kGroup }
|
||||||
|
|
||||||
|
class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate {
|
||||||
|
final Client client;
|
||||||
|
VoipPlugin(this.client) {
|
||||||
|
voip = VoIP(client, this);
|
||||||
|
try {
|
||||||
|
Connectivity()
|
||||||
|
.onConnectivityChanged
|
||||||
|
.listen(_handleNetworkChanged)
|
||||||
|
.onError((e) => _currentConnectivity = ConnectivityResult.none);
|
||||||
|
Connectivity()
|
||||||
|
.checkConnectivity()
|
||||||
|
.then((result) => _currentConnectivity = result)
|
||||||
|
.catchError((e) => _currentConnectivity = ConnectivityResult.none);
|
||||||
|
} catch (e) {
|
||||||
|
// currently broken on web
|
||||||
|
Logs().e('Listening to connectivity failed. $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kIsWeb) {
|
||||||
|
final wb = WidgetsBinding.instance;
|
||||||
|
wb.addObserver(this);
|
||||||
|
didChangeAppLifecycleState(wb.lifecycleState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool background = false;
|
||||||
|
bool speakerOn = false;
|
||||||
|
late VoIP voip;
|
||||||
|
ConnectivityResult? _currentConnectivity;
|
||||||
|
OverlayEntry? overlayEntry;
|
||||||
|
// void onPhoneButtonTap(BuildContext context, Room room) async {
|
||||||
|
// final callType = await showDialog<VoipType>(
|
||||||
|
// context: context,
|
||||||
|
// builder: (BuildContext context) {
|
||||||
|
// return SimpleDialog(
|
||||||
|
// titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 24.0),
|
||||||
|
// title: Row(
|
||||||
|
// mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
// children: <Widget>[
|
||||||
|
// ProfileScaffoldButton(
|
||||||
|
// key: const Key('CallProfileScaffoldVoiceCall'),
|
||||||
|
// iconData: FamedlyIconsV2.phone,
|
||||||
|
// onTap: () {
|
||||||
|
// Navigator.pop(context, VoipType.kVoice);
|
||||||
|
// },
|
||||||
|
// ),
|
||||||
|
// ProfileScaffoldButton(
|
||||||
|
// key: const Key('CallProfileScaffoldVideoCall'),
|
||||||
|
// iconData: Icons.videocam,
|
||||||
|
// onTap: () {
|
||||||
|
// Navigator.pop(context, VoipType.kVideo);
|
||||||
|
// },
|
||||||
|
// ),
|
||||||
|
// ProfileScaffoldButton(
|
||||||
|
// key: const Key('CallProfileScaffoldGroupCall'),
|
||||||
|
// iconData: Icons.people,
|
||||||
|
// onTap: () {
|
||||||
|
// Navigator.pop(context, VoipType.kGroup);
|
||||||
|
// },
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (callType == null) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// final success = await showFutureLoadingDialog(
|
||||||
|
// context: context,
|
||||||
|
// future: () =>
|
||||||
|
// Famedly.of(context).voipPlugin.voip.requestTurnServerCredentials());
|
||||||
|
// if (success.result != null) {
|
||||||
|
// final voipPlugin = Famedly.of(context).voipPlugin;
|
||||||
|
// if ({VoipType.kVideo, VoipType.kVoice}.contains(callType)) {
|
||||||
|
// await voipPlugin.voip
|
||||||
|
// .inviteToCall(room.id,
|
||||||
|
// callType == VoipType.kVoice ? CallType.kVoice : CallType.kVideo)
|
||||||
|
// .catchError((e) {
|
||||||
|
// ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
// SnackBar(
|
||||||
|
// content: Text(
|
||||||
|
// LocalizedExceptionExtension.toLocalizedString(context, e)),
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// final groupCall = await voipPlugin.voip.fetchOrCreateGroupCall(room.id);
|
||||||
|
// groupCall?.enter();
|
||||||
|
// Logs().e('Group call should be enter now');
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// await showOkAlertDialog(
|
||||||
|
// context: context,
|
||||||
|
// title: L10n.of(context)!.thisFeatureHasNotBeenImplementedYet,
|
||||||
|
// okLabel: L10n.of(context)!.next,
|
||||||
|
// useRootNavigator: false,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
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() {
|
||||||
|
final context = kIsWeb
|
||||||
|
? ChatList.contextForVoip!
|
||||||
|
: FluffyChatApp.routerKey.currentContext ?? ChatList.contextForVoip!;
|
||||||
|
if (overlayEntry != null) {
|
||||||
|
Logs().w('[VOIP] addCallingOverlay: The call session already exists?');
|
||||||
|
overlayEntry!.remove();
|
||||||
|
}
|
||||||
|
final overlay = Overlay.of(context)!;
|
||||||
|
overlayEntry = OverlayEntry(
|
||||||
|
builder: (_) => Calling(
|
||||||
|
voipPlugin: this,
|
||||||
|
onClear: () {
|
||||||
|
overlayEntry?.remove();
|
||||||
|
overlayEntry = null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
overlay.insert(overlayEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
CallSession? get currentCall =>
|
||||||
|
voip.currentCID == null ? null : voip.calls[voip.currentCID];
|
||||||
|
|
||||||
|
GroupCall? get currentGroupCall => voip.currentGroupCID == null
|
||||||
|
? null
|
||||||
|
: voip.groupCalls[voip.currentGroupCID];
|
||||||
|
|
||||||
|
bool inMeeting = false;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
voip.startGroupCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool getInMeetingState() {
|
||||||
|
return currentCall != null || currentGroupCall != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get currentMeetingRoomId {
|
||||||
|
return currentCall?.room.id ?? currentGroupCall?.room.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
CallSession? get call {
|
||||||
|
if (voip.currentCID != null) {
|
||||||
|
final call = voip.calls[voip.currentCID];
|
||||||
|
if (call != null && call.groupCallId != null) {
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupCall? get groupCall {
|
||||||
|
if (voip.currentCID != null) {
|
||||||
|
return voip.groupCalls[voip.currentGroupCID];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices;
|
||||||
|
|
||||||
|
@override
|
||||||
|
// remove this from sdk once callkeep is stable
|
||||||
|
bool get isBackgroud => false;
|
||||||
|
|
||||||
|
@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 && !await hasCallingAccount) {
|
||||||
|
try {
|
||||||
|
await UserMediaManager().startRingingTone();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void stopRingtone() async {
|
||||||
|
if (!background && !await hasCallingAccount) {
|
||||||
|
try {
|
||||||
|
await UserMediaManager().stopRingingTone();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> get hasCallingAccount async => PlatformInfos.isAndroid
|
||||||
|
? await CallKeepManager().hasPhoneAccountEnabled
|
||||||
|
: false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleNewCall(CallSession call) async {
|
||||||
|
Logs().d('[VoipPlugin] detected new call');
|
||||||
|
if (!getInMeetingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (call.direction == CallDirection.kIncoming &&
|
||||||
|
await hasCallingAccount &&
|
||||||
|
call.type == CallType.kVoice &&
|
||||||
|
Platform.isAndroid) {
|
||||||
|
///Popup native telecom manager call UI for incoming call.
|
||||||
|
final callKeeper = CallKeeper(CallKeepManager(), call);
|
||||||
|
CallKeepManager().addCall(call.callId, callKeeper);
|
||||||
|
await CallKeepManager().showCallkitIncoming(call);
|
||||||
|
} else {
|
||||||
|
if (!await hasCallingAccount || !Platform.isAndroid) {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
try {
|
||||||
|
ScaffoldMessenger.of(FluffyChatApp.routerKey.currentContext!)
|
||||||
|
.showSnackBar(const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No calling accounts found (used for native calls UI)',
|
||||||
|
)));
|
||||||
|
} catch (e) {
|
||||||
|
Logs().e('[VoipPlugin] Failed to show snackbar', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final wasForeground = await FlutterForegroundTask.isAppOnForeground;
|
||||||
|
await Store().setItem(
|
||||||
|
'wasForeground', wasForeground == true ? 'true' : 'false');
|
||||||
|
FlutterForegroundTask.setOnLockScreenVisibility(true);
|
||||||
|
FlutterForegroundTask.wakeUpScreen();
|
||||||
|
FlutterForegroundTask.launchApp();
|
||||||
|
} catch (e) {
|
||||||
|
Logs().e('VOIP foreground failed $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logs().d('[VoipPlugin] pushing flutter call overlay');
|
||||||
|
// use fallback flutter call pages for outgoing and video calls.
|
||||||
|
addCallingOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleCallEnded(CallSession session) async {
|
||||||
|
if (getInMeetingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overlayEntry != null) {
|
||||||
|
overlayEntry!.remove();
|
||||||
|
overlayEntry = null;
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
FlutterForegroundTask.setOnLockScreenVisibility(false);
|
||||||
|
FlutterForegroundTask.stopService();
|
||||||
|
final wasForeground = await Store().getItem('wasForeground');
|
||||||
|
wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleNewGroupCall(GroupCall groupCall) {
|
||||||
|
Logs().d('[VOIP] new call found');
|
||||||
|
if (!getInMeetingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Popup CallingPage for incoming call.
|
||||||
|
addCallingOverlay();
|
||||||
|
if (PlatformInfos.isAndroid) {
|
||||||
|
FlutterForegroundTask.setOnLockScreenVisibility(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logs().d('[VOIP] overlay stuff should be there up there');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleGroupCallEnded(GroupCall groupCall) {
|
||||||
|
if (overlayEntry != null) {
|
||||||
|
overlayEntry!.remove();
|
||||||
|
overlayEntry = null;
|
||||||
|
}
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
FlutterForegroundTask.setOnLockScreenVisibility(false);
|
||||||
|
FlutterForegroundTask.stopService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<webrtc_impl.MediaStream> cloneStream(
|
||||||
|
webrtc_impl.MediaStream stream) async {
|
||||||
|
final cloneStream = await webrtc_impl.createLocalMediaStream(stream.id);
|
||||||
|
stream.getTracks().forEach((track) {
|
||||||
|
cloneStream.addTrack(track, addToNative: true);
|
||||||
|
});
|
||||||
|
return cloneStream;
|
||||||
|
}
|
||||||
|
}
|
@ -10,10 +10,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import 'package:fluffychat/config/app_config.dart';
|
import 'package:fluffychat/config/app_config.dart';
|
||||||
import 'package:fluffychat/config/setting_keys.dart';
|
import 'package:fluffychat/config/setting_keys.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/utils/callkeep_manager.dart';
|
||||||
import 'package:fluffychat/utils/client_manager.dart';
|
import 'package:fluffychat/utils/client_manager.dart';
|
||||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||||
import 'package:fluffychat/utils/platform_infos.dart';
|
import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
import 'package:fluffychat/utils/voip/callkeep_manager.dart';
|
|
||||||
|
|
||||||
Future<void> pushHelper(
|
Future<void> pushHelper(
|
||||||
PushNotification notification, {
|
PushNotification notification, {
|
||||||
|
@ -1,212 +0,0 @@
|
|||||||
import 'dart:core';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl;
|
|
||||||
import 'package:matrix/matrix.dart';
|
|
||||||
import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator;
|
|
||||||
|
|
||||||
import 'package:fluffychat/pages/chat_list/chat_list.dart';
|
|
||||||
import 'package:fluffychat/pages/dialer/dialer.dart';
|
|
||||||
import 'package:fluffychat/utils/platform_infos.dart';
|
|
||||||
import 'package:fluffychat/widgets/fluffy_chat_app.dart';
|
|
||||||
import '../../utils/famedlysdk_store.dart';
|
|
||||||
import '../../utils/voip/callkeep_manager.dart';
|
|
||||||
import '../../utils/voip/user_media_manager.dart';
|
|
||||||
|
|
||||||
class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate {
|
|
||||||
final Client client;
|
|
||||||
VoipPlugin(this.client) {
|
|
||||||
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);
|
|
||||||
didChangeAppLifecycleState(wb.lifecycleState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bool background = false;
|
|
||||||
bool speakerOn = false;
|
|
||||||
late VoIP voip;
|
|
||||||
ConnectivityResult? _currentConnectivity;
|
|
||||||
OverlayEntry? overlayEntry;
|
|
||||||
|
|
||||||
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(String callId, CallSession call) {
|
|
||||||
final context = kIsWeb
|
|
||||||
? ChatList.contextForVoip!
|
|
||||||
: FluffyChatApp.routerKey.currentContext!; // web is weird
|
|
||||||
if (overlayEntry != null) {
|
|
||||||
Logs().e('[VOIP] addCallingOverlay: The call session already exists?');
|
|
||||||
overlayEntry!.remove();
|
|
||||||
}
|
|
||||||
// Overlay.of(context) is broken on web
|
|
||||||
// falling back on a dialog
|
|
||||||
if (kIsWeb) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Calling(
|
|
||||||
context: context,
|
|
||||||
client: client,
|
|
||||||
callId: callId,
|
|
||||||
call: call,
|
|
||||||
onClear: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
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
|
|
||||||
// remove this from sdk once callkeep is stable
|
|
||||||
bool get isBackgroud => false;
|
|
||||||
|
|
||||||
@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();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> get hasCallingAccount async =>
|
|
||||||
kIsWeb ? false : await CallKeepManager().hasPhoneAccountEnabled;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void playRingtone() async {
|
|
||||||
if (!background && !await hasCallingAccount) {
|
|
||||||
try {
|
|
||||||
await UserMediaManager().startRingingTone();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void stopRingtone() async {
|
|
||||||
if (!background && !await hasCallingAccount) {
|
|
||||||
try {
|
|
||||||
await UserMediaManager().stopRingingTone();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void handleNewCall(CallSession call) async {
|
|
||||||
if (PlatformInfos.isAndroid) {
|
|
||||||
// probably works on ios too
|
|
||||||
final hasCallingAccount = await CallKeepManager().hasPhoneAccountEnabled;
|
|
||||||
if (call.direction == CallDirection.kIncoming &&
|
|
||||||
hasCallingAccount &&
|
|
||||||
call.type == CallType.kVoice) {
|
|
||||||
///Popup native telecom manager call UI for incoming call.
|
|
||||||
final callKeeper = CallKeeper(CallKeepManager(), call);
|
|
||||||
CallKeepManager().addCall(call.callId, callKeeper);
|
|
||||||
await CallKeepManager().showCallkitIncoming(call);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
final wasForeground = await FlutterForegroundTask.isAppOnForeground;
|
|
||||||
await Store().setItem(
|
|
||||||
'wasForeground', wasForeground == true ? 'true' : 'false');
|
|
||||||
FlutterForegroundTask.setOnLockScreenVisibility(true);
|
|
||||||
FlutterForegroundTask.wakeUpScreen();
|
|
||||||
FlutterForegroundTask.launchApp();
|
|
||||||
} catch (e) {
|
|
||||||
Logs().e('VOIP foreground failed $e');
|
|
||||||
}
|
|
||||||
// use fallback flutter call pages for outgoing and video calls.
|
|
||||||
addCallingOverlay(call.callId, call);
|
|
||||||
try {
|
|
||||||
if (!hasCallingAccount) {
|
|
||||||
ScaffoldMessenger.of(FluffyChatApp.routerKey.currentContext!)
|
|
||||||
.showSnackBar(const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'No calling accounts found (used for native calls UI)',
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Logs().e('failed to show snackbar');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addCallingOverlay(call.callId, call);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void handleCallEnded(CallSession session) async {
|
|
||||||
if (overlayEntry != null) {
|
|
||||||
overlayEntry!.remove();
|
|
||||||
overlayEntry = null;
|
|
||||||
if (PlatformInfos.isAndroid) {
|
|
||||||
FlutterForegroundTask.setOnLockScreenVisibility(false);
|
|
||||||
FlutterForegroundTask.stopService();
|
|
||||||
final wasForeground = await Store().getItem('wasForeground');
|
|
||||||
wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void handleGroupCallEnded(GroupCall groupCall) {
|
|
||||||
// TODO: implement handleGroupCallEnded
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void handleNewGroupCall(GroupCall groupCall) {
|
|
||||||
// TODO: implement handleNewGroupCall
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<webrtc_impl.MediaStream> cloneStream(webrtc_impl.MediaStream stream) {
|
|
||||||
// TODO: implement cloneStream
|
|
||||||
throw UnimplementedError();
|
|
||||||
}
|
|
||||||
}
|
|
@ -23,11 +23,11 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import 'package:vrouter/vrouter.dart';
|
import 'package:vrouter/vrouter.dart';
|
||||||
|
|
||||||
import 'package:fluffychat/config/themes.dart';
|
import 'package:fluffychat/config/themes.dart';
|
||||||
|
import 'package:fluffychat/pages/voip/utils/voip_plugin.dart';
|
||||||
import 'package:fluffychat/utils/client_manager.dart';
|
import 'package:fluffychat/utils/client_manager.dart';
|
||||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||||
import 'package:fluffychat/utils/platform_infos.dart';
|
import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
import 'package:fluffychat/utils/uia_request_manager.dart';
|
import 'package:fluffychat/utils/uia_request_manager.dart';
|
||||||
import 'package:fluffychat/utils/voip_plugin.dart';
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../config/setting_keys.dart';
|
import '../config/setting_keys.dart';
|
||||||
import '../pages/key_verification/key_verification_dialog.dart';
|
import '../pages/key_verification/key_verification_dialog.dart';
|
||||||
@ -424,9 +424,13 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
void createVoipPlugin() async {
|
void createVoipPlugin() async {
|
||||||
if (await store.getItemBool(SettingKeys.experimentalVoip) == false) {
|
if (await store.getItemBool(SettingKeys.experimentalVoip) == false) {
|
||||||
|
Logs().e('webrtc disabled');
|
||||||
voipPlugin = null;
|
voipPlugin = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!webrtcIsSupported) {
|
||||||
|
Logs().e('webrtc not supported, not creating voipplugin');
|
||||||
|
}
|
||||||
voipPlugin = webrtcIsSupported ? VoipPlugin(client) : null;
|
voipPlugin = webrtcIsSupported ? VoipPlugin(client) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
16
pubspec.lock
16
pubspec.lock
@ -430,6 +430,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.2"
|
version: "0.1.2"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.5"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -664,6 +671,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.5"
|
version: "2.0.5"
|
||||||
|
flutter_reorderable_grid_view:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_reorderable_grid_view
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
flutter_ringtone_player:
|
flutter_ringtone_player:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -759,7 +773,7 @@ packages:
|
|||||||
name: flutter_webrtc
|
name: flutter_webrtc
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.5"
|
version: "0.9.6"
|
||||||
fuchsia_remote_debug_protocol:
|
fuchsia_remote_debug_protocol:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@ -44,13 +44,14 @@ dependencies:
|
|||||||
flutter_matrix_html: ^1.1.0
|
flutter_matrix_html: ^1.1.0
|
||||||
flutter_olm: ^1.2.0
|
flutter_olm: ^1.2.0
|
||||||
flutter_openssl_crypto: ^0.1.0
|
flutter_openssl_crypto: ^0.1.0
|
||||||
|
flutter_reorderable_grid_view: ^3.1.3
|
||||||
flutter_ringtone_player: ^3.1.1
|
flutter_ringtone_player: ^3.1.1
|
||||||
flutter_secure_storage: ^6.0.0
|
flutter_secure_storage: ^6.0.0
|
||||||
flutter_slidable: ^2.0.0
|
flutter_slidable: ^2.0.0
|
||||||
flutter_svg: ^0.22.0
|
flutter_svg: ^0.22.0
|
||||||
flutter_typeahead: ^4.0.0
|
flutter_typeahead: ^4.0.0
|
||||||
flutter_web_auth: ^0.4.0
|
flutter_web_auth: ^0.4.0
|
||||||
flutter_webrtc: ^0.9.5
|
flutter_webrtc: ^0.9.6
|
||||||
future_loading_dialog: ^0.2.3
|
future_loading_dialog: ^0.2.3
|
||||||
geolocator: ^7.6.2
|
geolocator: ^7.6.2
|
||||||
handy_window: ^0.1.6
|
handy_window: ^0.1.6
|
||||||
@ -119,8 +120,8 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/
|
- assets/
|
||||||
- assets/sounds/
|
- assets/sounds/
|
||||||
- assets/js/
|
- assets/assets/js/
|
||||||
- assets/js/package/
|
- assets/assets/js/package/
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: Roboto
|
- family: Roboto
|
||||||
|
Loading…
x
Reference in New Issue
Block a user