♻️ Replace livekit with jitsi in calling
This commit is contained in:
		| @@ -1,369 +0,0 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class ControlsWidget extends StatefulWidget { | ||||
|   final Room room; | ||||
|   final LocalParticipant participant; | ||||
|  | ||||
|   const ControlsWidget( | ||||
|     this.room, | ||||
|     this.participant, { | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _ControlsWidgetState(); | ||||
| } | ||||
|  | ||||
| class _ControlsWidgetState extends State<ControlsWidget> { | ||||
|   CameraPosition _position = CameraPosition.front; | ||||
|  | ||||
|   List<MediaDevice>? _audioInputs; | ||||
|   List<MediaDevice>? _audioOutputs; | ||||
|   List<MediaDevice>? _videoInputs; | ||||
|  | ||||
|   StreamSubscription? _subscription; | ||||
|  | ||||
|   bool _speakerphoneOn = false; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _participant.addListener(onChange); | ||||
|     _subscription = Hardware.instance.onDeviceChange.stream | ||||
|         .listen((List<MediaDevice> devices) { | ||||
|       _revertDevices(devices); | ||||
|     }); | ||||
|     Hardware.instance.enumerateDevices().then(_revertDevices); | ||||
|     _speakerphoneOn = Hardware.instance.speakerOn ?? false; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _subscription?.cancel(); | ||||
|     _participant.removeListener(onChange); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   LocalParticipant get _participant => widget.participant; | ||||
|  | ||||
|   void _revertDevices(List<MediaDevice> devices) async { | ||||
|     _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); | ||||
|     _audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList(); | ||||
|     _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void onChange() => setState(() {}); | ||||
|  | ||||
|   bool get isMuted => _participant.isMuted; | ||||
|  | ||||
|   Future<bool?> showDisconnectDialog() { | ||||
|     return showDialog<bool>( | ||||
|       context: context, | ||||
|       builder: (ctx) => AlertDialog( | ||||
|         title: Text('callDisconnect').tr(), | ||||
|         content: Text('callDisconnectDescription').tr(), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(ctx, false), | ||||
|             child: Text('cancel').tr(), | ||||
|           ), | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(ctx, true), | ||||
|             child: Text('dialogConfirm').tr(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _disconnect() async { | ||||
|     if (await showDisconnectDialog() != true) return; | ||||
|     if (!mounted) return; | ||||
|  | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     if (call.current != null) { | ||||
|       call.disposeRoom(); | ||||
|       if (Navigator.canPop(context)) { | ||||
|         Navigator.pop(context); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _disableAudio() async { | ||||
|     await _participant.setMicrophoneEnabled(false); | ||||
|   } | ||||
|  | ||||
|   void _enableAudio() async { | ||||
|     await _participant.setMicrophoneEnabled(true); | ||||
|   } | ||||
|  | ||||
|   void _disableVideo() async { | ||||
|     await _participant.setCameraEnabled(false); | ||||
|   } | ||||
|  | ||||
|   void _enableVideo() async { | ||||
|     await _participant.setCameraEnabled(true); | ||||
|   } | ||||
|  | ||||
|   void _selectAudioOutput(MediaDevice device) async { | ||||
|     await widget.room.setAudioOutputDevice(device); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _selectAudioInput(MediaDevice device) async { | ||||
|     await widget.room.setAudioInputDevice(device); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _selectVideoInput(MediaDevice device) async { | ||||
|     await widget.room.setVideoInputDevice(device); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _toggleSpeakerphoneOn() { | ||||
|     _speakerphoneOn = !_speakerphoneOn; | ||||
|     Hardware.instance.setSpeakerphoneOn(_speakerphoneOn); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _toggleCamera() async { | ||||
|     final track = _participant.videoTrackPublications.firstOrNull?.track; | ||||
|     if (track == null) return; | ||||
|  | ||||
|     try { | ||||
|       final newPosition = _position.switched(); | ||||
|       await track.setCameraPosition(newPosition); | ||||
|       setState(() { | ||||
|         _position = newPosition; | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       return; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _enableScreenShare() async { | ||||
|     if (lkPlatformIsDesktop()) { | ||||
|       try { | ||||
|         final source = await showDialog<DesktopCapturerSource>( | ||||
|           context: context, | ||||
|           builder: (context) => ScreenSelectDialog(), | ||||
|         ); | ||||
|         if (source == null) { | ||||
|           return; | ||||
|         } | ||||
|         var track = await LocalVideoTrack.createScreenShareTrack( | ||||
|           ScreenShareCaptureOptions( | ||||
|             captureScreenAudio: true, | ||||
|             sourceId: source.id, | ||||
|             maxFrameRate: 30.0, | ||||
|           ), | ||||
|         ); | ||||
|         await _participant.publishVideoTrack(track); | ||||
|       } catch (err) { | ||||
|         if (!mounted) return; | ||||
|         context.showErrorDialog(err); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     if (lkPlatformIs(PlatformType.iOS)) { | ||||
|       var track = await LocalVideoTrack.createScreenShareTrack( | ||||
|         const ScreenShareCaptureOptions( | ||||
|           useiOSBroadcastExtension: true, | ||||
|           captureScreenAudio: true, | ||||
|           maxFrameRate: 30.0, | ||||
|         ), | ||||
|       ); | ||||
|       await _participant.publishVideoTrack(track); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (lkPlatformIsWebMobile()) { | ||||
|       ScaffoldMessenger.of(context).showSnackBar(const SnackBar( | ||||
|         content: Text('Screen share is not supported mobile platform.'), | ||||
|       )); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await _participant.setScreenShareEnabled(true, captureScreenAudio: true); | ||||
|   } | ||||
|  | ||||
|   void _disableScreenShare() async { | ||||
|     await _participant.setScreenShareEnabled(false); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.symmetric( | ||||
|         vertical: 10, | ||||
|       ), | ||||
|       child: Wrap( | ||||
|         alignment: WrapAlignment.center, | ||||
|         spacing: 5, | ||||
|         runSpacing: 5, | ||||
|         children: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.exit_to_app), | ||||
|             color: Theme.of(context).colorScheme.onSurface, | ||||
|             onPressed: _disconnect, | ||||
|           ), | ||||
|           if (_participant.isMicrophoneEnabled()) | ||||
|             if (lkPlatformIs(PlatformType.android)) | ||||
|               IconButton( | ||||
|                 onPressed: _disableAudio, | ||||
|                 icon: const Icon(Symbols.mic), | ||||
|                 color: Theme.of(context).colorScheme.onSurface, | ||||
|                 tooltip: 'callMicrophoneOff'.tr(), | ||||
|               ) | ||||
|             else | ||||
|               PopupMenuButton<MediaDevice>( | ||||
|                 icon: const Icon(Symbols.settings_voice), | ||||
|                 itemBuilder: (BuildContext context) { | ||||
|                   return [ | ||||
|                     PopupMenuItem<MediaDevice>( | ||||
|                       value: null, | ||||
|                       onTap: isMuted ? _enableAudio : _disableAudio, | ||||
|                       child: ListTile( | ||||
|                         leading: const Icon(Symbols.mic_off), | ||||
|                         title: Text(isMuted | ||||
|                             ? 'callMicrophoneOn'.tr() | ||||
|                             : 'callMicrophoneOff'.tr()), | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (_audioInputs != null) | ||||
|                       ..._audioInputs!.map((device) { | ||||
|                         return PopupMenuItem<MediaDevice>( | ||||
|                           value: device, | ||||
|                           child: ListTile( | ||||
|                             leading: (device.deviceId == | ||||
|                                     widget.room.selectedAudioInputDeviceId) | ||||
|                                 ? const Icon(Symbols.check_box) | ||||
|                                 : const Icon(Symbols.check_box_outline_blank), | ||||
|                             title: Text(device.label), | ||||
|                           ), | ||||
|                           onTap: () => _selectAudioInput(device), | ||||
|                         ); | ||||
|                       }) | ||||
|                   ]; | ||||
|                 }, | ||||
|               ) | ||||
|           else | ||||
|             IconButton( | ||||
|               onPressed: _enableAudio, | ||||
|               icon: const Icon(Symbols.mic_off), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               tooltip: 'callMicrophoneOn'.tr(), | ||||
|             ), | ||||
|           if (_participant.isCameraEnabled()) | ||||
|             PopupMenuButton<MediaDevice>( | ||||
|               icon: const Icon(Symbols.videocam_sharp), | ||||
|               itemBuilder: (BuildContext context) { | ||||
|                 return [ | ||||
|                   PopupMenuItem<MediaDevice>( | ||||
|                     value: null, | ||||
|                     onTap: _disableVideo, | ||||
|                     child: ListTile( | ||||
|                       leading: const Icon(Symbols.videocam_off), | ||||
|                       title: Text('callCameraOff'.tr()), | ||||
|                     ), | ||||
|                   ), | ||||
|                   if (_videoInputs != null) | ||||
|                     ..._videoInputs!.map((device) { | ||||
|                       return PopupMenuItem<MediaDevice>( | ||||
|                         value: device, | ||||
|                         child: ListTile( | ||||
|                           leading: (device.deviceId == | ||||
|                                   widget.room.selectedVideoInputDeviceId) | ||||
|                               ? const Icon(Symbols.check_box) | ||||
|                               : const Icon(Symbols.check_box_outline_blank), | ||||
|                           title: Text(device.label), | ||||
|                         ), | ||||
|                         onTap: () => _selectVideoInput(device), | ||||
|                       ); | ||||
|                     }) | ||||
|                 ]; | ||||
|               }, | ||||
|             ) | ||||
|           else | ||||
|             IconButton( | ||||
|               onPressed: _enableVideo, | ||||
|               icon: const Icon(Symbols.videocam_off), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               tooltip: 'callCameraOn'.tr(), | ||||
|             ), | ||||
|           IconButton( | ||||
|             icon: Icon(_position == CameraPosition.back | ||||
|                 ? Symbols.video_camera_back | ||||
|                 : Symbols.video_camera_front), | ||||
|             color: Theme.of(context).colorScheme.onSurface, | ||||
|             onPressed: () => _toggleCamera(), | ||||
|             tooltip: 'callVideoFlip'.tr(), | ||||
|           ), | ||||
|           if (!lkPlatformIs(PlatformType.iOS)) | ||||
|             PopupMenuButton<MediaDevice>( | ||||
|               icon: const Icon(Symbols.volume_up), | ||||
|               itemBuilder: (BuildContext context) { | ||||
|                 return [ | ||||
|                   PopupMenuItem<MediaDevice>( | ||||
|                     value: null, | ||||
|                     child: ListTile( | ||||
|                       leading: const Icon(Symbols.speaker), | ||||
|                       title: Text('callSpeakerSelect').tr(), | ||||
|                     ), | ||||
|                   ), | ||||
|                   if (_audioOutputs != null) | ||||
|                     ..._audioOutputs!.map((device) { | ||||
|                       return PopupMenuItem<MediaDevice>( | ||||
|                         value: device, | ||||
|                         child: ListTile( | ||||
|                           leading: (device.deviceId == | ||||
|                                   widget.room.selectedAudioOutputDeviceId) | ||||
|                               ? const Icon(Symbols.check_box) | ||||
|                               : const Icon(Symbols.check_box_outline_blank), | ||||
|                           title: Text(device.label), | ||||
|                         ), | ||||
|                         onTap: () => _selectAudioOutput(device), | ||||
|                       ); | ||||
|                     }) | ||||
|                 ]; | ||||
|               }, | ||||
|             ), | ||||
|           if (!kIsWeb && Hardware.instance.canSwitchSpeakerphone) | ||||
|             IconButton( | ||||
|               onPressed: _toggleSpeakerphoneOn, | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               icon: _speakerphoneOn | ||||
|                   ? Icon(Symbols.volume_up) | ||||
|                   : Icon(Symbols.volume_down), | ||||
|               tooltip: 'callSpeakerphoneToggle'.tr(), | ||||
|             ), | ||||
|           if (_participant.isScreenShareEnabled()) | ||||
|             IconButton( | ||||
|               icon: const Icon(Symbols.stop_screen_share), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               onPressed: () => _disableScreenShare(), | ||||
|               tooltip: 'callScreenOff'.tr(), | ||||
|             ) | ||||
|           else | ||||
|             IconButton( | ||||
|               icon: const Icon(Symbols.screen_share), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               onPressed: () => _enableScreenShare(), | ||||
|               tooltip: 'callScreenOn'.tr(), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,86 +0,0 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_animate/flutter_animate.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
|  | ||||
| class NoContentWidget extends StatefulWidget { | ||||
|   final SnAccount? userinfo; | ||||
|   final bool isSpeaking; | ||||
|   final double? avatarSize; | ||||
|  | ||||
|   const NoContentWidget({ | ||||
|     super.key, | ||||
|     this.userinfo, | ||||
|     this.avatarSize, | ||||
|     required this.isSpeaking, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<NoContentWidget> createState() => _NoContentWidgetState(); | ||||
| } | ||||
|  | ||||
| class _NoContentWidgetState extends State<NoContentWidget> | ||||
|     with SingleTickerProviderStateMixin { | ||||
|   late final AnimationController _animationController; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _animationController = AnimationController(vsync: this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void didUpdateWidget(NoContentWidget old) { | ||||
|     super.didUpdateWidget(old); | ||||
|     if (widget.isSpeaking) { | ||||
|       _animationController.repeat(reverse: true); | ||||
|     } else { | ||||
|       _animationController | ||||
|           .animateTo(0, duration: 300.ms) | ||||
|           .then((_) => _animationController.reset()); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final double radius = widget.avatarSize ?? | ||||
|         math.min( | ||||
|           MediaQuery.of(context).size.width * 0.1, | ||||
|           MediaQuery.of(context).size.height * 0.1, | ||||
|         ); | ||||
|  | ||||
|     return Animate( | ||||
|       autoPlay: false, | ||||
|       controller: _animationController, | ||||
|       effects: [ | ||||
|         CustomEffect( | ||||
|           begin: widget.isSpeaking ? 2 : 0, | ||||
|           end: 8, | ||||
|           curve: Curves.easeInOut, | ||||
|           duration: 1250.ms, | ||||
|           builder: (context, value, child) => Container( | ||||
|             decoration: BoxDecoration( | ||||
|               borderRadius: BorderRadius.all(Radius.circular(radius + 8)), | ||||
|               border: value > 0 | ||||
|                   ? Border.all(color: Colors.green, width: value) | ||||
|                   : null, | ||||
|             ), | ||||
|             child: child, | ||||
|           ), | ||||
|         ) | ||||
|       ], | ||||
|       child: AccountImage( | ||||
|         content: widget.userinfo?.avatar, | ||||
|         radius: radius, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _animationController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
| @@ -1,337 +0,0 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_no_content.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant_info.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant_menu.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant_stats.dart'; | ||||
|  | ||||
| abstract class ParticipantWidget extends StatefulWidget { | ||||
|   static ParticipantWidget widgetFor( | ||||
|     ParticipantTrack participantTrack, { | ||||
|     double? avatarSize, | ||||
|     EdgeInsets? padding, | ||||
|     bool showStatsLayer = false, | ||||
|     bool isList = false, | ||||
|   }) { | ||||
|     if (participantTrack.participant is LocalParticipant) { | ||||
|       return LocalParticipantWidget( | ||||
|         participantTrack.participant as LocalParticipant, | ||||
|         participantTrack.videoTrack, | ||||
|         avatarSize, | ||||
|         participantTrack.isScreenShare, | ||||
|         showStatsLayer, | ||||
|         isList, | ||||
|         padding, | ||||
|       ); | ||||
|     } else if (participantTrack.participant is RemoteParticipant) { | ||||
|       return RemoteParticipantWidget( | ||||
|         participantTrack.participant as RemoteParticipant, | ||||
|         participantTrack.videoTrack, | ||||
|         avatarSize, | ||||
|         participantTrack.isScreenShare, | ||||
|         showStatsLayer, | ||||
|         isList, | ||||
|         padding, | ||||
|       ); | ||||
|     } | ||||
|     throw UnimplementedError('Unknown participant type'); | ||||
|   } | ||||
|  | ||||
|   abstract final Participant participant; | ||||
|   abstract final VideoTrack? videoTrack; | ||||
|   abstract final bool isScreenShare; | ||||
|   abstract final double? avatarSize; | ||||
|   abstract final bool showStatsLayer; | ||||
|   abstract final bool isList; | ||||
|   abstract final EdgeInsets? padding; | ||||
|   final VideoQuality quality; | ||||
|  | ||||
|   const ParticipantWidget({ | ||||
|     super.key, | ||||
|     this.quality = VideoQuality.MEDIUM, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class LocalParticipantWidget extends ParticipantWidget { | ||||
|   @override | ||||
|   final LocalParticipant participant; | ||||
|   @override | ||||
|   final VideoTrack? videoTrack; | ||||
|   @override | ||||
|   final double? avatarSize; | ||||
|   @override | ||||
|   final bool isScreenShare; | ||||
|   @override | ||||
|   final bool showStatsLayer; | ||||
|   @override | ||||
|   final bool isList; | ||||
|   @override | ||||
|   final EdgeInsets? padding; | ||||
|  | ||||
|   const LocalParticipantWidget( | ||||
|     this.participant, | ||||
|     this.videoTrack, | ||||
|     this.avatarSize, | ||||
|     this.isScreenShare, | ||||
|     this.showStatsLayer, | ||||
|     this.isList, | ||||
|     this.padding, { | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _LocalParticipantWidgetState(); | ||||
| } | ||||
|  | ||||
| class RemoteParticipantWidget extends ParticipantWidget { | ||||
|   @override | ||||
|   final RemoteParticipant participant; | ||||
|   @override | ||||
|   final VideoTrack? videoTrack; | ||||
|   @override | ||||
|   final double? avatarSize; | ||||
|   @override | ||||
|   final bool isScreenShare; | ||||
|   @override | ||||
|   final bool showStatsLayer; | ||||
|   @override | ||||
|   final bool isList; | ||||
|   @override | ||||
|   final EdgeInsets? padding; | ||||
|  | ||||
|   const RemoteParticipantWidget( | ||||
|     this.participant, | ||||
|     this.videoTrack, | ||||
|     this.avatarSize, | ||||
|     this.isScreenShare, | ||||
|     this.showStatsLayer, | ||||
|     this.isList, | ||||
|     this.padding, { | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _RemoteParticipantWidgetState(); | ||||
| } | ||||
|  | ||||
| abstract class _ParticipantWidgetState<T extends ParticipantWidget> | ||||
|     extends State<T> { | ||||
|   VideoTrack? get _activeVideoTrack; | ||||
|  | ||||
|   TrackPublication? get _firstAudioPublication; | ||||
|  | ||||
|   SnAccount? _userinfoMetadata; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     widget.participant.addListener(onParticipantChanged); | ||||
|     onParticipantChanged(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     widget.participant.removeListener(onParticipantChanged); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void didUpdateWidget(covariant T oldWidget) { | ||||
|     oldWidget.participant.removeListener(onParticipantChanged); | ||||
|     widget.participant.addListener(onParticipantChanged); | ||||
|     onParticipantChanged(); | ||||
|     super.didUpdateWidget(oldWidget); | ||||
|   } | ||||
|  | ||||
|   void onParticipantChanged() { | ||||
|     setState(() { | ||||
|       if (widget.participant.metadata != null) { | ||||
|         _userinfoMetadata = SnAccount.fromJson( | ||||
|           jsonDecode(widget.participant.metadata!), | ||||
|         ); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (widget.isList) { | ||||
|       return Padding( | ||||
|         padding: widget.padding ?? EdgeInsets.zero, | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Row( | ||||
|               children: [ | ||||
|                 SizedBox( | ||||
|                   width: (widget.avatarSize ?? 32) * 2, | ||||
|                   height: (widget.avatarSize ?? 32) * 2, | ||||
|                   child: Center( | ||||
|                     child: NoContentWidget( | ||||
|                       userinfo: _userinfoMetadata, | ||||
|                       avatarSize: widget.avatarSize, | ||||
|                       isSpeaking: widget.participant.isSpeaking, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 const Gap(8), | ||||
|                 Expanded( | ||||
|                   child: SizedBox( | ||||
|                     height: (widget.avatarSize ?? 32) * 2, | ||||
|                     child: ParticipantInfoWidget( | ||||
|                       isList: true, | ||||
|                       title: widget.participant.name.isNotEmpty | ||||
|                           ? widget.participant.name | ||||
|                           : widget.participant.identity, | ||||
|                       audioAvailable: _firstAudioPublication?.muted == false && | ||||
|                           _firstAudioPublication?.subscribed == true, | ||||
|                       connectionQuality: widget.participant.connectionQuality, | ||||
|                       isScreenShare: widget.isScreenShare, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             if (_activeVideoTrack != null && !_activeVideoTrack!.muted) | ||||
|               ClipRRect( | ||||
|                 borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                 child: AspectRatio( | ||||
|                   aspectRatio: 16 / 9, | ||||
|                   child: Material( | ||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                     color: Theme.of(context) | ||||
|                         .colorScheme | ||||
|                         .surfaceContainer | ||||
|                         .withOpacity(0.75), | ||||
|                     child: VideoTrackRenderer( | ||||
|                       _activeVideoTrack!, | ||||
|                       fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ).padding(top: 8), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Stack( | ||||
|       children: [ | ||||
|         if (_activeVideoTrack != null && !_activeVideoTrack!.muted) | ||||
|           VideoTrackRenderer( | ||||
|             _activeVideoTrack!, | ||||
|             fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||
|           ) | ||||
|         else | ||||
|           Center( | ||||
|             child: NoContentWidget( | ||||
|               userinfo: _userinfoMetadata, | ||||
|               avatarSize: widget.avatarSize, | ||||
|               isSpeaking: widget.participant.isSpeaking, | ||||
|             ), | ||||
|           ), | ||||
|         if (widget.showStatsLayer) | ||||
|           Positioned( | ||||
|             top: 30, | ||||
|             right: 30, | ||||
|             child: ParticipantStatsWidget(participant: widget.participant), | ||||
|           ), | ||||
|         Align( | ||||
|           alignment: Alignment.bottomCenter, | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               ParticipantInfoWidget( | ||||
|                 title: widget.participant.name.isNotEmpty | ||||
|                     ? widget.participant.name | ||||
|                     : widget.participant.identity, | ||||
|                 audioAvailable: _firstAudioPublication?.muted == false && | ||||
|                     _firstAudioPublication?.subscribed == true, | ||||
|                 connectionQuality: widget.participant.connectionQuality, | ||||
|                 isScreenShare: widget.isScreenShare, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _LocalParticipantWidgetState | ||||
|     extends _ParticipantWidgetState<LocalParticipantWidget> { | ||||
|   @override | ||||
|   LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   @override | ||||
|   VideoTrack? get _activeVideoTrack => widget.videoTrack; | ||||
| } | ||||
|  | ||||
| class _RemoteParticipantWidgetState | ||||
|     extends _ParticipantWidgetState<RemoteParticipantWidget> { | ||||
|   @override | ||||
|   RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   @override | ||||
|   VideoTrack? get _activeVideoTrack => widget.videoTrack; | ||||
| } | ||||
|  | ||||
| class InteractiveParticipantWidget extends StatelessWidget { | ||||
|   final double? avatarSize; | ||||
|   final bool isList; | ||||
|   final ParticipantTrack participant; | ||||
|   final Function? onTap; | ||||
|   final EdgeInsets? padding; | ||||
|  | ||||
|   const InteractiveParticipantWidget({ | ||||
|     super.key, | ||||
|     this.avatarSize, | ||||
|     this.isList = false, | ||||
|     this.padding, | ||||
|     required this.participant, | ||||
|     this.onTap, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Material( | ||||
|       color: Colors.transparent, | ||||
|       child: InkWell( | ||||
|         onTap: onTap != null | ||||
|             ? () { | ||||
|                 onTap?.call(); | ||||
|               } | ||||
|             : null, | ||||
|         onLongPress: () { | ||||
|           if (participant.participant is LocalParticipant) return; | ||||
|           showModalBottomSheet( | ||||
|             context: context, | ||||
|             builder: (context) => ParticipantMenu( | ||||
|               participant: participant.participant as RemoteParticipant, | ||||
|               videoTrack: participant.videoTrack, | ||||
|               isScreenShare: participant.isScreenShare, | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|         child: Container( | ||||
|           child: ParticipantWidget.widgetFor( | ||||
|             participant, | ||||
|             avatarSize: avatarSize, | ||||
|             isList: isList, | ||||
|             padding: padding, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,140 +0,0 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class ParticipantInfoWidget extends StatelessWidget { | ||||
|   final String? title; | ||||
|   final bool audioAvailable; | ||||
|   final ConnectionQuality connectionQuality; | ||||
|   final bool isScreenShare; | ||||
|   final bool isList; | ||||
|  | ||||
|   const ParticipantInfoWidget({ | ||||
|     super.key, | ||||
|     this.title, | ||||
|     this.audioAvailable = true, | ||||
|     this.connectionQuality = ConnectionQuality.unknown, | ||||
|     this.isScreenShare = false, | ||||
|     this.isList = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (isList) { | ||||
|       return Column( | ||||
|         mainAxisAlignment: MainAxisAlignment.center, | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           if (title != null) | ||||
|             Text( | ||||
|               title!, | ||||
|               overflow: TextOverflow.ellipsis, | ||||
|               style: const TextStyle( | ||||
|                 color: Colors.white, | ||||
|                 fontWeight: FontWeight.bold, | ||||
|               ), | ||||
|             ).padding(left: 2), | ||||
|           Row( | ||||
|             children: [ | ||||
|               isScreenShare | ||||
|                   ? const Icon( | ||||
|                       Symbols.monitor, | ||||
|                       color: Colors.white, | ||||
|                       size: 16, | ||||
|                     ) | ||||
|                   : Icon( | ||||
|                       audioAvailable ? Symbols.mic : Symbols.mic_off, | ||||
|                       color: audioAvailable ? Colors.white : Colors.red, | ||||
|                       size: 16, | ||||
|                     ), | ||||
|               const Gap(3), | ||||
|               if (connectionQuality != ConnectionQuality.unknown) | ||||
|                 Icon( | ||||
|                   { | ||||
|                     ConnectionQuality.excellent: Symbols.signal_cellular_alt, | ||||
|                     ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar, | ||||
|                     ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar, | ||||
|                   }[connectionQuality], | ||||
|                   color: { | ||||
|                     ConnectionQuality.excellent: Colors.green, | ||||
|                     ConnectionQuality.good: Colors.orange, | ||||
|                     ConnectionQuality.poor: Colors.red, | ||||
|                   }[connectionQuality], | ||||
|                   size: 16, | ||||
|                 ) | ||||
|               else | ||||
|                 const SizedBox( | ||||
|                   width: 12, | ||||
|                   height: 12, | ||||
|                   child: CircularProgressIndicator( | ||||
|                     color: Colors.white, | ||||
|                     strokeWidth: 2, | ||||
|                   ), | ||||
|                 ).padding(all: 3), | ||||
|             ], | ||||
|           ) | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||
|       padding: const EdgeInsets.symmetric( | ||||
|         vertical: 7, | ||||
|         horizontal: 10, | ||||
|       ), | ||||
|       child: Row( | ||||
|         mainAxisAlignment: MainAxisAlignment.end, | ||||
|         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|         children: [ | ||||
|           if (title != null) | ||||
|             Flexible( | ||||
|               child: Text( | ||||
|                 title!, | ||||
|                 overflow: TextOverflow.ellipsis, | ||||
|                 style: const TextStyle(color: Colors.white), | ||||
|               ), | ||||
|             ), | ||||
|           const Gap(5), | ||||
|           isScreenShare | ||||
|               ? const Icon( | ||||
|                   Symbols.monitor, | ||||
|                   color: Colors.white, | ||||
|                   size: 16, | ||||
|                 ) | ||||
|               : Icon( | ||||
|                   audioAvailable ? Symbols.mic : Symbols.mic_off, | ||||
|                   color: audioAvailable ? Colors.white : Colors.red, | ||||
|                   size: 16, | ||||
|                 ), | ||||
|           const Gap(3), | ||||
|           if (connectionQuality != ConnectionQuality.unknown) | ||||
|             Icon( | ||||
|               { | ||||
|                 ConnectionQuality.excellent: Symbols.signal_cellular_alt, | ||||
|                 ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar, | ||||
|                 ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar, | ||||
|               }[connectionQuality], | ||||
|               color: { | ||||
|                 ConnectionQuality.excellent: Colors.green, | ||||
|                 ConnectionQuality.good: Colors.orange, | ||||
|                 ConnectionQuality.poor: Colors.red, | ||||
|               }[connectionQuality], | ||||
|               size: 16, | ||||
|             ) | ||||
|           else | ||||
|             const SizedBox( | ||||
|               width: 12, | ||||
|               height: 12, | ||||
|               child: CircularProgressIndicator( | ||||
|                 color: Colors.white, | ||||
|                 strokeWidth: 2, | ||||
|               ), | ||||
|             ).padding(all: 3), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,161 +0,0 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class ParticipantMenu extends StatefulWidget { | ||||
|   final RemoteParticipant participant; | ||||
|   final VideoTrack? videoTrack; | ||||
|   final bool isScreenShare; | ||||
|   final bool showStatsLayer; | ||||
|  | ||||
|   const ParticipantMenu({ | ||||
|     super.key, | ||||
|     required this.participant, | ||||
|     this.videoTrack, | ||||
|     this.isScreenShare = false, | ||||
|     this.showStatsLayer = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<ParticipantMenu> createState() => _ParticipantMenuState(); | ||||
| } | ||||
|  | ||||
| class _ParticipantMenuState extends State<ParticipantMenu> { | ||||
|   RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication => | ||||
|       widget.participant.videoTrackPublications | ||||
|           .where((element) => element.sid == widget.videoTrack?.sid) | ||||
|           .firstOrNull; | ||||
|  | ||||
|   RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   void tookAction() { | ||||
|     if (Navigator.canPop(context)) { | ||||
|       Navigator.pop(context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Container( | ||||
|           padding: | ||||
|               const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12), | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.symmetric( | ||||
|               horizontal: 8, | ||||
|               vertical: 12, | ||||
|             ), | ||||
|             child: Text( | ||||
|               'callParticipantAction', | ||||
|               style: Theme.of(context).textTheme.headlineSmall, | ||||
|             ).tr(), | ||||
|           ), | ||||
|         ), | ||||
|         Expanded( | ||||
|           child: ListView( | ||||
|             children: [ | ||||
|               if (_firstAudioPublication != null && !widget.isScreenShare) | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     Symbols.volume_up, | ||||
|                     color: { | ||||
|                       TrackSubscriptionState.notAllowed: | ||||
|                           Theme.of(context).colorScheme.error, | ||||
|                       TrackSubscriptionState.unsubscribed: Theme.of(context) | ||||
|                           .colorScheme | ||||
|                           .onSurface | ||||
|                           .withOpacity(0.6), | ||||
|                       TrackSubscriptionState.subscribed: | ||||
|                           Theme.of(context).colorScheme.primary, | ||||
|                     }[_firstAudioPublication!.subscriptionState], | ||||
|                   ), | ||||
|                   title: Text( | ||||
|                     _firstAudioPublication!.subscribed | ||||
|                         ? 'callParticipantMicrophoneOff'.tr() | ||||
|                         : 'callParticipantMicrophoneOn'.tr(), | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     if (_firstAudioPublication!.subscribed) { | ||||
|                       _firstAudioPublication!.unsubscribe(); | ||||
|                     } else { | ||||
|                       _firstAudioPublication!.subscribe(); | ||||
|                     } | ||||
|                     tookAction(); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (_videoPublication != null) | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     widget.isScreenShare ? Symbols.monitor : Symbols.videocam, | ||||
|                     color: { | ||||
|                       TrackSubscriptionState.notAllowed: | ||||
|                           Theme.of(context).colorScheme.error, | ||||
|                       TrackSubscriptionState.unsubscribed: Theme.of(context) | ||||
|                           .colorScheme | ||||
|                           .onSurface | ||||
|                           .withOpacity(0.6), | ||||
|                       TrackSubscriptionState.subscribed: | ||||
|                           Theme.of(context).colorScheme.primary, | ||||
|                     }[_videoPublication!.subscriptionState], | ||||
|                   ), | ||||
|                   title: Text( | ||||
|                     _videoPublication!.subscribed | ||||
|                         ? 'callParticipantVideoOff'.tr() | ||||
|                         : 'callParticipantVideoOn'.tr(), | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     if (_videoPublication!.subscribed) { | ||||
|                       _videoPublication!.unsubscribe(); | ||||
|                     } else { | ||||
|                       _videoPublication!.subscribe(); | ||||
|                     } | ||||
|                     tookAction(); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (_videoPublication != null) const Divider(thickness: 0.3), | ||||
|               if (_videoPublication != null) | ||||
|                 ...[30, 15, 8].map( | ||||
|                   (x) => ListTile( | ||||
|                     leading: Icon( | ||||
|                       _videoPublication?.fps == x | ||||
|                           ? Symbols.check_box | ||||
|                           : Symbols.check_box_outline_blank, | ||||
|                     ), | ||||
|                     title: Text('Set preferred frame-per-second to $x'), | ||||
|                     onTap: () { | ||||
|                       _videoPublication!.setVideoFPS(x); | ||||
|                       tookAction(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (_videoPublication != null) const Divider(thickness: 0.3), | ||||
|               if (_videoPublication != null) | ||||
|                 ...[ | ||||
|                   ('High', VideoQuality.HIGH), | ||||
|                   ('Medium', VideoQuality.MEDIUM), | ||||
|                   ('Low', VideoQuality.LOW), | ||||
|                 ].map( | ||||
|                   (x) => ListTile( | ||||
|                     leading: Icon( | ||||
|                       _videoPublication?.videoQuality == x.$2 | ||||
|                           ? Symbols.check_box | ||||
|                           : Symbols.check_box_outline_blank, | ||||
|                     ), | ||||
|                     title: Text('Set preferred quality to ${x.$1}'), | ||||
|                     onTap: () { | ||||
|                       _videoPublication!.setVideoQuality(x.$2); | ||||
|                       tookAction(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,133 +0,0 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
|  | ||||
| class ParticipantStatsWidget extends StatefulWidget { | ||||
|   const ParticipantStatsWidget({super.key, required this.participant}); | ||||
|  | ||||
|   final Participant participant; | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _ParticipantStatsWidgetState(); | ||||
| } | ||||
|  | ||||
| class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> { | ||||
|   List<EventsListener<TrackEvent>> listeners = []; | ||||
|   ParticipantStatsType statsType = ParticipantStatsType.unknown; | ||||
|   Map<String, String> stats = {}; | ||||
|  | ||||
|   void _setUpListener(Track track) { | ||||
|     var listener = track.createListener(); | ||||
|     listeners.add(listener); | ||||
|     if (track is LocalVideoTrack) { | ||||
|       statsType = ParticipantStatsType.localVideoSender; | ||||
|       listener.on<VideoSenderStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs'; | ||||
|           event.stats.forEach((key, value) { | ||||
|             stats['layer-$key'] = | ||||
|                 '${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps'; | ||||
|           }); | ||||
|           var firstStats = | ||||
|               event.stats['f'] ?? event.stats['h'] ?? event.stats['q']; | ||||
|           if (firstStats != null) { | ||||
|             stats['encoder'] = firstStats.encoderImplementation ?? ''; | ||||
|             stats['video codec'] = | ||||
|                 '${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}'; | ||||
|             stats['qualityLimitationReason'] = | ||||
|                 firstStats.qualityLimitationReason ?? ''; | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|     } else if (track is RemoteVideoTrack) { | ||||
|       statsType = ParticipantStatsType.remoteVideoReceiver; | ||||
|       listener.on<VideoReceiverStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['video rx'] = '${event.currentBitrate.toInt()} kpbs'; | ||||
|           stats['video codec'] = | ||||
|               '${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}'; | ||||
|           stats['video size'] = | ||||
|               '${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps'; | ||||
|           stats['video jitter'] = '${event.stats.jitter} s'; | ||||
|           stats['video decoder'] = '${event.stats.decoderImplementation}'; | ||||
|           stats['video packets lost'] = '${event.stats.packetsLost}'; | ||||
|           stats['video packets received'] = '${event.stats.packetsReceived}'; | ||||
|           stats['video frames received'] = '${event.stats.framesReceived}'; | ||||
|           stats['video frames decoded'] = '${event.stats.framesDecoded}'; | ||||
|           stats['video frames dropped'] = '${event.stats.framesDropped}'; | ||||
|         }); | ||||
|       }); | ||||
|     } else if (track is LocalAudioTrack) { | ||||
|       statsType = ParticipantStatsType.localAudioSender; | ||||
|       listener.on<AudioSenderStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs'; | ||||
|           stats['audio codec'] = | ||||
|               '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}'; | ||||
|         }); | ||||
|       }); | ||||
|     } else if (track is RemoteAudioTrack) { | ||||
|       statsType = ParticipantStatsType.remoteAudioReceiver; | ||||
|       listener.on<AudioReceiverStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs'; | ||||
|           stats['audio codec'] = | ||||
|               '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}'; | ||||
|           stats['audio jitter'] = '${event.stats.jitter} s'; | ||||
|           stats['audio concealed samples'] = | ||||
|               '${event.stats.concealedSamples} / ${event.stats.concealmentEvents}'; | ||||
|           stats['audio packets lost'] = '${event.stats.packetsLost}'; | ||||
|           stats['audio packets received'] = '${event.stats.packetsReceived}'; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onParticipantChanged() { | ||||
|     for (var element in listeners) { | ||||
|       element.dispose(); | ||||
|     } | ||||
|     listeners.clear(); | ||||
|     for (var track in [ | ||||
|       ...widget.participant.videoTrackPublications, | ||||
|       ...widget.participant.audioTrackPublications | ||||
|     ]) { | ||||
|       if (track.track != null) { | ||||
|         _setUpListener(track.track!); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     widget.participant.addListener(onParticipantChanged); | ||||
|     onParticipantChanged(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void deactivate() { | ||||
|     for (var element in listeners) { | ||||
|       element.dispose(); | ||||
|     } | ||||
|     widget.participant.removeListener(onParticipantChanged); | ||||
|     super.deactivate(); | ||||
|   } | ||||
|  | ||||
|   num sendBitrate = 0; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Container( | ||||
|       color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||
|       padding: const EdgeInsets.symmetric( | ||||
|         vertical: 8, | ||||
|         horizontal: 8, | ||||
|       ), | ||||
|       child: Column( | ||||
|         children: | ||||
|             stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,191 +0,0 @@ | ||||
|     import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class ChatCallPrejoinPopup extends StatefulWidget { | ||||
|   final SnChatCall ongoingCall; | ||||
|   final SnChannel channel; | ||||
|   final void Function() onJoin; | ||||
|  | ||||
|   const ChatCallPrejoinPopup({ | ||||
|     super.key, | ||||
|     required this.ongoingCall, | ||||
|     required this.channel, | ||||
|     required this.onJoin, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<ChatCallPrejoinPopup> createState() => _ChatCallPrejoinPopupState(); | ||||
| } | ||||
|  | ||||
| class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   late final ChatCallProvider _call = context.read<ChatCallProvider>(); | ||||
|  | ||||
|   void _performJoin() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     _call.setCall(widget.ongoingCall, widget.channel); | ||||
|     _call.setIsBusy(true); | ||||
|  | ||||
|     try { | ||||
|       final resp = await _call.getRoomToken(); | ||||
|       final token = resp.$1; | ||||
|       final endpoint = resp.$2; | ||||
|  | ||||
|       _call.initRoom(); | ||||
|       _call.setupRoomListeners( | ||||
|         onDisconnected: (reason) { | ||||
|           context.showSnackbar( | ||||
|             'callDisconnected'.tr(args: [reason.toString()]), | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|       await _call.joinRoom(endpoint, token); | ||||
|       widget.onJoin(); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context); | ||||
|     } catch (e) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(e); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     call.checkPermissions().then((_) { | ||||
|       call.initHardware(); | ||||
|     }); | ||||
|  | ||||
|     super.initState(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     return ListenableBuilder( | ||||
|       listenable: call, | ||||
|       builder: (context, _) { | ||||
|         return Center( | ||||
|           child: Container( | ||||
|             constraints: const BoxConstraints(maxWidth: 320), | ||||
|             child: Column( | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                   children: [ | ||||
|                     Text('callMicrophone').tr(), | ||||
|                     Switch( | ||||
|                       value: call.enableAudio, | ||||
|                       onChanged: null, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(bottom: 5), | ||||
|                 DropdownButtonHideUnderline( | ||||
|                   child: DropdownButton2<MediaDevice>( | ||||
|                     isExpanded: true, | ||||
|                     disabledHint: Text('callMicrophoneDisabled').tr(), | ||||
|                     hint: Text('callMicrophoneSelect').tr(), | ||||
|                     items: call.enableAudio | ||||
|                         ? call.audioInputs | ||||
|                             .map( | ||||
|                               (item) => DropdownMenuItem<MediaDevice>( | ||||
|                                 value: item, | ||||
|                                 child: Text(item.label), | ||||
|                               ), | ||||
|                             ) | ||||
|                             .toList() | ||||
|                             .cast<DropdownMenuItem<MediaDevice>>() | ||||
|                         : [], | ||||
|                     value: call.audioDevice, | ||||
|                     onChanged: (MediaDevice? value) async { | ||||
|                       if (value != null) { | ||||
|                         call.setAudioDevice(value); | ||||
|                         await call.changeLocalAudioTrack(); | ||||
|                       } | ||||
|                     }, | ||||
|                     buttonStyleData: const ButtonStyleData( | ||||
|                       height: 40, | ||||
|                       width: 320, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ).padding(bottom: 25), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                   children: [ | ||||
|                     Text('callCamera').tr(), | ||||
|                     Switch( | ||||
|                       value: call.enableVideo, | ||||
|                       onChanged: call.setEnableVideo, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(bottom: 5), | ||||
|                 DropdownButtonHideUnderline( | ||||
|                   child: DropdownButton2<MediaDevice>( | ||||
|                     isExpanded: true, | ||||
|                     disabledHint: Text('callCameraDisabled').tr(), | ||||
|                     hint: Text('callCameraSelect').tr(), | ||||
|                     items: call.enableVideo | ||||
|                         ? call.videoInputs | ||||
|                             .map( | ||||
|                               (item) => DropdownMenuItem<MediaDevice>( | ||||
|                                 value: item, | ||||
|                                 child: Text(item.label), | ||||
|                               ), | ||||
|                             ) | ||||
|                             .toList() | ||||
|                             .cast<DropdownMenuItem<MediaDevice>>() | ||||
|                         : [], | ||||
|                     value: call.videoDevice, | ||||
|                     onChanged: (MediaDevice? value) async { | ||||
|                       if (value != null) { | ||||
|                         call.setVideoDevice(value); | ||||
|                         await call.changeLocalVideoTrack(); | ||||
|                       } | ||||
|                     }, | ||||
|                     buttonStyleData: const ButtonStyleData( | ||||
|                       height: 40, | ||||
|                       width: 320, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ).padding(bottom: 25), | ||||
|                 if (_isBusy) | ||||
|                   const Center(child: CircularProgressIndicator()) | ||||
|                 else | ||||
|                   ElevatedButton( | ||||
|                     style: ElevatedButton.styleFrom( | ||||
|                       minimumSize: const Size(320, 56), | ||||
|                     ), | ||||
|                     onPressed: _isBusy ? null : _performJoin, | ||||
|                     child: Text('callJoin').tr(), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _call | ||||
|       ..deactivateHardware() | ||||
|       ..disposeHardware(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user