💄 Optimized voice chat
This commit is contained in:
		| @@ -24,8 +24,8 @@ | ||||
|             android:name="de.julianassmann.flutter_background.IsolateHolderService" | ||||
|             android:enabled="true" | ||||
|             android:exported="false" | ||||
|  | ||||
|             android:foregroundServiceType="mediaProjection" /> | ||||
|  | ||||
|         <activity | ||||
|             android:name=".MainActivity" | ||||
|             android:exported="true" | ||||
|   | ||||
| @@ -147,12 +147,12 @@ class _ChatCallState extends State<ChatCall> { | ||||
|  | ||||
|   void autoPublish() async { | ||||
|     try { | ||||
|       if (_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true); | ||||
|       if(_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true); | ||||
|     } catch (error) { | ||||
|       await context.showErrorDialog(error); | ||||
|     } | ||||
|     try { | ||||
|       if (_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true); | ||||
|       if(_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true); | ||||
|     } catch (error) { | ||||
|       await context.showErrorDialog(error); | ||||
|     } | ||||
| @@ -204,17 +204,23 @@ class _ChatCallState extends State<ChatCall> { | ||||
|     List<ParticipantTrack> userMediaTracks = []; | ||||
|     List<ParticipantTrack> screenTracks = []; | ||||
|     for (var participant in _callRoom.remoteParticipants.values) { | ||||
|       for (var t in participant.videoTrackPublications) { | ||||
|       for (var t in participant.trackPublications.values) { | ||||
|         if (t.isScreenShare) { | ||||
|           screenTracks.add(ParticipantTrack( | ||||
|             participant: participant, | ||||
|             videoTrack: t.track, | ||||
|             videoTrack: t.track as VideoTrack, | ||||
|             isScreenShare: true, | ||||
|           )); | ||||
|         } else if (t.track is VideoTrack) { | ||||
|           userMediaTracks.add(ParticipantTrack( | ||||
|             participant: participant, | ||||
|             videoTrack: t.track as VideoTrack, | ||||
|             isScreenShare: false, | ||||
|           )); | ||||
|         } else { | ||||
|           userMediaTracks.add(ParticipantTrack( | ||||
|             participant: participant, | ||||
|             videoTrack: t.track, | ||||
|             videoTrack: null, | ||||
|             isScreenShare: false, | ||||
|           )); | ||||
|         } | ||||
| @@ -247,19 +253,25 @@ class _ChatCallState extends State<ChatCall> { | ||||
|       return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; | ||||
|     }); | ||||
|  | ||||
|     final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications; | ||||
|     final localParticipantTracks = _callRoom.localParticipant?.trackPublications.values; | ||||
|     if (localParticipantTracks != null) { | ||||
|       for (var t in localParticipantTracks) { | ||||
|         if (t.isScreenShare) { | ||||
|           screenTracks.add(ParticipantTrack( | ||||
|             participant: _callRoom.localParticipant!, | ||||
|             videoTrack: t.track, | ||||
|             videoTrack: t.track as VideoTrack, | ||||
|             isScreenShare: true, | ||||
|           )); | ||||
|         } else if (t.track is VideoTrack) { | ||||
|           userMediaTracks.add(ParticipantTrack( | ||||
|             participant: _callRoom.localParticipant!, | ||||
|             videoTrack: t.track as VideoTrack, | ||||
|             isScreenShare: false, | ||||
|           )); | ||||
|         } else { | ||||
|           userMediaTracks.add(ParticipantTrack( | ||||
|             participant: _callRoom.localParticipant!, | ||||
|             videoTrack: t.track, | ||||
|             videoTrack: null, | ||||
|             isScreenShare: false, | ||||
|           )); | ||||
|         } | ||||
| @@ -363,7 +375,6 @@ class _ChatCallState extends State<ChatCall> { | ||||
|   Widget build(BuildContext context) { | ||||
|     return IndentWrapper( | ||||
|       title: AppLocalizations.of(context)!.chatCall, | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       child: FutureBuilder( | ||||
|         future: exchangeToken(), | ||||
| @@ -377,15 +388,14 @@ class _ChatCallState extends State<ChatCall> { | ||||
|               Column( | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: Container( | ||||
|                       color: Theme.of(context).colorScheme.surfaceVariant, | ||||
|                       child: _participantTracks.isNotEmpty | ||||
|                           ? ParticipantWidget.widgetFor(_participantTracks.first) | ||||
|                           : Container(), | ||||
|                     ), | ||||
|                   if (_callRoom.localParticipant != null) | ||||
|                     SafeArea( | ||||
|                       top: false, | ||||
|                       child: ControlsWidget(_callRoom, _callRoom.localParticipant!), | ||||
|                     ) | ||||
|                   ), | ||||
|                   if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!), | ||||
|                 ], | ||||
|               ), | ||||
|               Positioned( | ||||
| @@ -398,7 +408,7 @@ class _ChatCallState extends State<ChatCall> { | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     itemCount: math.max(0, _participantTracks.length - 1), | ||||
|                     itemBuilder: (BuildContext context, int index) => SizedBox( | ||||
|                       width: 180, | ||||
|                       width: 120, | ||||
|                       height: 120, | ||||
|                       child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), | ||||
|                     ), | ||||
| @@ -423,8 +433,8 @@ class _ChatCallState extends State<ChatCall> { | ||||
|     WakelockPlus.disable(); | ||||
|     (() async { | ||||
|       _callRoom.removeListener(onRoomDidUpdate); | ||||
|       await _callRoom.disconnect(); | ||||
|       await _callListener.dispose(); | ||||
|       await _callRoom.disconnect(); | ||||
|       await _callRoom.dispose(); | ||||
|     })(); | ||||
|     super.dispose(); | ||||
|   | ||||
| @@ -294,7 +294,7 @@ class _ControlsWidgetState extends State<ControlsWidget> { | ||||
|                     value: null, | ||||
|                     onTap: disableVideo, | ||||
|                     child: ListTile( | ||||
|                       leading: const Icon(Icons.videocam_off, color: Colors.white), | ||||
|                       leading: const Icon(Icons.videocam_off), | ||||
|                       title: Text(AppLocalizations.of(context)!.chatCallVideoOff), | ||||
|                     ), | ||||
|                   ), | ||||
|   | ||||
							
								
								
									
										75
									
								
								lib/widgets/chat/call/no_content.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								lib/widgets/chat/call/no_content.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_animate/flutter_animate.dart'; | ||||
| import 'package:solian/models/account.dart'; | ||||
| import 'package:solian/widgets/account/avatar.dart'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| class NoContentWidget extends StatefulWidget { | ||||
|   final Account? userinfo; | ||||
|   final bool isSpeaking; | ||||
|  | ||||
|   const NoContentWidget({super.key, this.userinfo, 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 radius = math.min(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height) * 0.1; | ||||
|  | ||||
|     return Container( | ||||
|       alignment: Alignment.center, | ||||
|       child: Center( | ||||
|         child: 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: AccountAvatar( | ||||
|               source: widget.userinfo!.avatar, | ||||
|               radius: radius, | ||||
|               direct: true, | ||||
|             )), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _animationController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
| @@ -1,18 +0,0 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| class NoVideoWidget extends StatelessWidget { | ||||
|   const NoVideoWidget({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Container( | ||||
|     alignment: Alignment.center, | ||||
|     child: LayoutBuilder( | ||||
|       builder: (ctx, constraints) => Icon( | ||||
|         Icons.videocam_off_outlined, | ||||
|         color: Theme.of(context).colorScheme.primary, | ||||
|         size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3, | ||||
|       ), | ||||
|     ), | ||||
|   ); | ||||
| } | ||||
| @@ -1,8 +1,11 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:solian/models/account.dart'; | ||||
| import 'package:solian/models/call.dart'; | ||||
| import 'package:solian/widgets/chat/call/no_video.dart'; | ||||
| import 'package:solian/widgets/chat/call/no_content.dart'; | ||||
| import 'package:solian/widgets/chat/call/participant_info.dart'; | ||||
| import 'package:solian/widgets/chat/call/participant_stats.dart'; | ||||
|  | ||||
| @@ -83,6 +86,8 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|  | ||||
|   TrackPublication? get _firstAudioPublication; | ||||
|  | ||||
|   Account? _userinfoMetadata; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
| @@ -104,23 +109,19 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|     super.didUpdateWidget(oldWidget); | ||||
|   } | ||||
|  | ||||
|   void onParticipantChanged() => setState(() {}); | ||||
|   void onParticipantChanged() { | ||||
|     setState(() { | ||||
|       if (widget.participant.metadata != null) { | ||||
|         _userinfoMetadata = Account.fromJson(jsonDecode(widget.participant.metadata!)); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   List<Widget> extraWidgets(bool isScreenShare) => []; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext ctx) => Container( | ||||
|         foregroundDecoration: BoxDecoration( | ||||
|           border: widget.participant.isSpeaking && !widget.isScreenShare | ||||
|               ? Border.all( | ||||
|                   width: 5, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ) | ||||
|               : null, | ||||
|         ), | ||||
|         decoration: BoxDecoration( | ||||
|           color: Theme.of(ctx).cardColor, | ||||
|         ), | ||||
|   Widget build(BuildContext ctx) { | ||||
|     return Container( | ||||
|       child: Stack( | ||||
|         children: [ | ||||
|           // Video | ||||
| @@ -131,7 +132,10 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|                     _activeVideoTrack!, | ||||
|                     fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||
|                   ) | ||||
|                   : const NoVideoWidget(), | ||||
|                 : NoContentWidget( | ||||
|                     userinfo: _userinfoMetadata, | ||||
|                     isSpeaking: widget.participant.isSpeaking, | ||||
|                   ), | ||||
|           ), | ||||
|           if (widget.showStatsLayer) | ||||
|             Positioned( | ||||
| @@ -139,7 +143,8 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|               right: 30, | ||||
|               child: ParticipantStatsWidget( | ||||
|                 participant: widget.participant, | ||||
|                   )), | ||||
|               ), | ||||
|             ), | ||||
|           // Bottom bar | ||||
|           Align( | ||||
|             alignment: Alignment.bottomCenter, | ||||
| @@ -152,8 +157,7 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|                   title: widget.participant.name.isNotEmpty | ||||
|                       ? '${widget.participant.name} (${widget.participant.identity})' | ||||
|                       : widget.participant.identity, | ||||
|                     audioAvailable: | ||||
|                         _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, | ||||
|                   audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, | ||||
|                   connectionQuality: widget.participant.connectionQuality, | ||||
|                   isScreenShare: widget.isScreenShare, | ||||
|                 ), | ||||
| @@ -163,6 +167,7 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> { | ||||
| @@ -202,7 +207,6 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic | ||||
|                 pub: _firstAudioPublication!, | ||||
|                 icon: Icons.volume_up, | ||||
|               ), | ||||
|             // Menu for RemoteTrackPublication<RemoteVideoTrack> | ||||
|             if (_videoPublication != null) | ||||
|               RemoteTrackPublicationMenuWidget( | ||||
|                 pub: _videoPublication!, | ||||
|   | ||||
| @@ -17,7 +17,7 @@ class ParticipantInfoWidget extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Container( | ||||
|         color: Colors.black.withOpacity(0.3), | ||||
|         color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||
|         padding: const EdgeInsets.symmetric( | ||||
|           vertical: 7, | ||||
|           horizontal: 10, | ||||
| @@ -31,6 +31,7 @@ class ParticipantInfoWidget extends StatelessWidget { | ||||
|                 child: Text( | ||||
|                   title!, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                   style: const TextStyle(color: Colors.white), | ||||
|                 ), | ||||
|               ), | ||||
|             isScreenShare | ||||
|   | ||||
| @@ -30,5 +30,11 @@ | ||||
| 	<string>public.app-category.social-networking</string> | ||||
| 	<key>NSPrincipalClass</key> | ||||
| 	<string>NSApplication</string> | ||||
|     <key>NSPhotoLibraryUsageDescription</key> | ||||
|     <string>Allow you add photo to your message or post</string> | ||||
|     <key>NSCameraUsageDescription</key> | ||||
|     <string>Allow you take photo/video for your message or post</string> | ||||
|     <key>NSMicrophoneUsageDescription</key> | ||||
|     <string>Allow you record audio for your message or post</string> | ||||
| </dict> | ||||
| </plist> | ||||
|   | ||||
| @@ -14,6 +14,8 @@ | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.network.client</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.network.server</key> | ||||
| 	<true/> | ||||
| 	<key>keychain-access-groups</key> | ||||
| 	<array/> | ||||
| </dict> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user