mirror of
				https://gitlab.com/famedly/fluffychat.git
				synced 2025-10-31 12:07:24 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			601 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			601 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| /*
 | |
|  *   Famedly
 | |
|  *   Copyright (C) 2019, 2020, 2021 Famedly GmbH
 | |
|  *
 | |
|  *   This program is free software: you can redistribute it and/or modify
 | |
|  *   it under the terms of the GNU Affero General Public License as
 | |
|  *   published by the Free Software Foundation, either version 3 of the
 | |
|  *   License, or (at your option) any later version.
 | |
|  *
 | |
|  *   This program is distributed in the hope that it will be useful,
 | |
|  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
|  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 | |
|  *   GNU Affero General Public License for more details.
 | |
|  *
 | |
|  *   You should have received a copy of the GNU Affero General Public License
 | |
|  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | |
|  */
 | |
| 
 | |
| import 'dart:async';
 | |
| import 'dart:math';
 | |
| 
 | |
| import 'package: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);
 | |
|                           },
 | |
|                         ))
 | |
|                 ]));
 | |
|           }));
 | |
|     });
 | |
|   }
 | |
| }
 | 
