From 541df5c3bcc7f3eb7e6d4ff712187405a371ffb8 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 28 Apr 2024 00:07:32 +0800 Subject: [PATCH] :lipstick: Optimized voice chat --- android/app/src/main/AndroidManifest.xml | 2 +- lib/screens/chat/call.dart | 48 +++++---- lib/widgets/chat/call/controls.dart | 2 +- lib/widgets/chat/call/no_content.dart | 75 +++++++++++++ lib/widgets/chat/call/no_video.dart | 18 ---- lib/widgets/chat/call/participant.dart | 112 ++++++++++---------- lib/widgets/chat/call/participant_info.dart | 3 +- macos/Runner/Info.plist | 6 ++ macos/Runner/Release.entitlements | 2 + 9 files changed, 174 insertions(+), 94 deletions(-) create mode 100644 lib/widgets/chat/call/no_content.dart delete mode 100644 lib/widgets/chat/call/no_video.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3a9a9b0..aface36 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,8 +24,8 @@ android:name="de.julianassmann.flutter_background.IsolateHolderService" android:enabled="true" android:exported="false" - android:foregroundServiceType="mediaProjection" /> + { 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 { List userMediaTracks = []; List 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 { 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 { 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 { Column( children: [ Expanded( - child: _participantTracks.isNotEmpty - ? ParticipantWidget.widgetFor(_participantTracks.first) - : Container(), + 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 { 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 { WakelockPlus.disable(); (() async { _callRoom.removeListener(onRoomDidUpdate); - await _callRoom.disconnect(); await _callListener.dispose(); + await _callRoom.disconnect(); await _callRoom.dispose(); })(); super.dispose(); diff --git a/lib/widgets/chat/call/controls.dart b/lib/widgets/chat/call/controls.dart index 63073fc..a64be26 100644 --- a/lib/widgets/chat/call/controls.dart +++ b/lib/widgets/chat/call/controls.dart @@ -294,7 +294,7 @@ class _ControlsWidgetState extends State { 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), ), ), diff --git a/lib/widgets/chat/call/no_content.dart b/lib/widgets/chat/call/no_content.dart new file mode 100644 index 0000000..b0c05da --- /dev/null +++ b/lib/widgets/chat/call/no_content.dart @@ -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 createState() => _NoContentWidgetState(); +} + +class _NoContentWidgetState extends State 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(); + } +} diff --git a/lib/widgets/chat/call/no_video.dart b/lib/widgets/chat/call/no_video.dart deleted file mode 100644 index ee09f25..0000000 --- a/lib/widgets/chat/call/no_video.dart +++ /dev/null @@ -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, - ), - ), - ); -} \ No newline at end of file diff --git a/lib/widgets/chat/call/participant.dart b/lib/widgets/chat/call/participant.dart index 053bbee..7891863 100644 --- a/lib/widgets/chat/call/participant.dart +++ b/lib/widgets/chat/call/participant.dart @@ -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 extends Stat TrackPublication? get _firstAudioPublication; + Account? _userinfoMetadata; + @override void initState() { super.initState(); @@ -104,65 +109,65 @@ abstract class _ParticipantWidgetState extends Stat super.didUpdateWidget(oldWidget); } - void onParticipantChanged() => setState(() {}); + void onParticipantChanged() { + setState(() { + if (widget.participant.metadata != null) { + _userinfoMetadata = Account.fromJson(jsonDecode(widget.participant.metadata!)); + } + }); + } List 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, - ), - child: Stack( - children: [ - // Video - InkWell( - onTap: () => setState(() => _visible = !_visible), - child: _activeVideoTrack != null && !_activeVideoTrack!.muted - ? VideoTrackRenderer( - _activeVideoTrack!, - fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, - ) - : const NoVideoWidget(), - ), - if (widget.showStatsLayer) - Positioned( - top: 30, - right: 30, - child: ParticipantStatsWidget( - participant: widget.participant, - )), - // Bottom bar - Align( - alignment: Alignment.bottomCenter, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ...extraWidgets(widget.isScreenShare), - ParticipantInfoWidget( - title: widget.participant.name.isNotEmpty - ? '${widget.participant.name} (${widget.participant.identity})' - : widget.participant.identity, - audioAvailable: - _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, - connectionQuality: widget.participant.connectionQuality, - isScreenShare: widget.isScreenShare, + Widget build(BuildContext ctx) { + return Container( + child: Stack( + children: [ + // Video + InkWell( + onTap: () => setState(() => _visible = !_visible), + child: _activeVideoTrack != null && !_activeVideoTrack!.muted + ? VideoTrackRenderer( + _activeVideoTrack!, + fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + ) + : NoContentWidget( + userinfo: _userinfoMetadata, + isSpeaking: widget.participant.isSpeaking, ), - ], + ), + if (widget.showStatsLayer) + Positioned( + top: 30, + right: 30, + child: ParticipantStatsWidget( + participant: widget.participant, ), ), - ], - ), - ); + // Bottom bar + Align( + alignment: Alignment.bottomCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ...extraWidgets(widget.isScreenShare), + ParticipantInfoWidget( + title: widget.participant.name.isNotEmpty + ? '${widget.participant.name} (${widget.participant.identity})' + : widget.participant.identity, + audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, + connectionQuality: widget.participant.connectionQuality, + isScreenShare: widget.isScreenShare, + ), + ], + ), + ), + ], + ), + ); + } } class _LocalParticipantWidgetState extends _ParticipantWidgetState { @@ -202,7 +207,6 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState if (_videoPublication != null) RemoteTrackPublicationMenuWidget( pub: _videoPublication!, diff --git a/lib/widgets/chat/call/participant_info.dart b/lib/widgets/chat/call/participant_info.dart index fc918c4..8279691 100644 --- a/lib/widgets/chat/call/participant_info.dart +++ b/lib/widgets/chat/call/participant_info.dart @@ -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 diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 7cfa70c..f8d877f 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -30,5 +30,11 @@ public.app-category.social-networking NSPrincipalClass NSApplication + NSPhotoLibraryUsageDescription + Allow you add photo to your message or post + NSCameraUsageDescription + Allow you take photo/video for your message or post + NSMicrophoneUsageDescription + Allow you record audio for your message or post diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 27481de..78d0da3 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -14,6 +14,8 @@ com.apple.security.network.client + com.apple.security.network.server + keychain-access-groups