From e665b44507944f48c683676742b6151f07c11fdd Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 28 Apr 2024 23:05:59 +0800 Subject: [PATCH] :sparkles: Optimized UI of call actions --- lib/providers/notify.dart | 3 +- lib/screens/chat/call.dart | 91 +++++++++-- lib/screens/notification.dart | 10 +- lib/widgets/chat/call/no_content.dart | 1 + lib/widgets/chat/call/participant.dart | 167 ++------------------ lib/widgets/chat/call/participant_menu.dart | 148 +++++++++++++++++ 6 files changed, 244 insertions(+), 176 deletions(-) create mode 100644 lib/widgets/chat/call/participant_menu.dart diff --git a/lib/providers/notify.dart b/lib/providers/notify.dart index a9e5ce7..d18c593 100644 --- a/lib/providers/notify.dart +++ b/lib/providers/notify.dart @@ -101,8 +101,9 @@ class NotifyProvider extends ChangeNotifier { notifyListeners(); } - void clearNonRealtime() { + void clearRealtime() { notifications = notifications.where((x) => !x.isRealtime).toList(); + notifyListeners(); } void allRead() { diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index 63532fe..e87914f 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -11,6 +11,7 @@ import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/chat/call/controls.dart'; import 'package:solian/widgets/chat/call/exts.dart'; import 'package:solian/widgets/chat/call/participant.dart'; +import 'package:solian/widgets/chat/call/participant_menu.dart'; import 'package:solian/widgets/indent_wrapper.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -49,9 +50,10 @@ class _ChatCallState extends State { late EventsListener _callListener; List _participantTracks = []; + ParticipantTrack? _focusParticipant; Future checkPermissions() async { - if(lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return; + if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return; await Permission.camera.request(); await Permission.microphone.request(); @@ -275,6 +277,7 @@ class _ChatCallState extends State { setState(() { _participantTracks = [...screenTracks, localTrack, ...userMediaTrackList]; + _focusParticipant ??= _participantTracks.first; }); } @@ -386,8 +389,11 @@ class _ChatCallState extends State { Expanded( child: Container( color: Theme.of(context).colorScheme.surfaceVariant, - child: _participantTracks.isNotEmpty - ? ParticipantWidget.widgetFor(_participantTracks.first) + child: _focusParticipant != null + ? InteractiveParticipantWidget( + participant: _focusParticipant!, + onTap: () {}, + ) : Container(), ), ), @@ -399,22 +405,34 @@ class _ChatCallState extends State { right: 0, top: 0, child: SizedBox( - height: 120 + 16, + height: 128, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: math.max(0, _participantTracks.length - 1), - itemBuilder: (BuildContext context, int index) => Padding( - padding: const EdgeInsets.all(8.0), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: Container( - width: 120, - height: 120, - color: Theme.of(context).cardColor, - child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), + itemCount: math.max(0, _participantTracks.length), + itemBuilder: (BuildContext context, int index) { + final track = _participantTracks[index]; + if (track.participant.sid == _focusParticipant?.participant.sid) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8, left: 8), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: InteractiveParticipantWidget( + width: 120, + height: 120, + color: Theme.of(context).cardColor, + participant: track, + onTap: () { + if (track.participant.sid != _focusParticipant?.participant.sid) { + setState(() => _focusParticipant = track); + } + }, + ), ), - ), - ), + ); + }, ), ), ), @@ -443,3 +461,44 @@ class _ChatCallState extends State { super.dispose(); } } + +class InteractiveParticipantWidget extends StatelessWidget { + final double? width; + final double? height; + final Color? color; + final ParticipantTrack participant; + final Function() onTap; + + const InteractiveParticipantWidget({ + super.key, + this.width, + this.height, + this.color, + required this.participant, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + child: Container( + width: width, + height: height, + color: color, + child: ParticipantWidget.widgetFor(participant), + ), + onTap: () => onTap(), + onLongPress: () { + if (participant.participant is LocalParticipant) return; + showModalBottomSheet( + context: context, + builder: (context) => ParticipantMenu( + participant: participant.participant as RemoteParticipant, + videoTrack: participant.videoTrack, + isScreenShare: participant.isScreenShare, + ), + ); + }, + ); + } +} diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index 14b99c3..36b2563 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -21,8 +21,6 @@ class _NotificationScreenState extends State { final auth = context.read(); final nty = context.watch(); - WidgetsBinding.instance.addPostFrameCallback((_) => nty.allRead()); - return IndentWrapper( noSafeArea: true, hideDrawer: true, @@ -71,6 +69,14 @@ class _NotificationScreenState extends State { ), ); } + + @override + void dispose() { + final nty = context.read(); + nty.allRead(); + nty.clearRealtime(); + super.dispose(); + } } class NotificationItem extends StatelessWidget { diff --git a/lib/widgets/chat/call/no_content.dart b/lib/widgets/chat/call/no_content.dart index b0c05da..3fd5882 100644 --- a/lib/widgets/chat/call/no_content.dart +++ b/lib/widgets/chat/call/no_content.dart @@ -60,6 +60,7 @@ class _NoContentWidgetState extends State with SingleTickerProv ], child: AccountAvatar( source: widget.userinfo!.avatar, + backgroundColor: Colors.transparent, radius: radius, direct: true, )), diff --git a/lib/widgets/chat/call/participant.dart b/lib/widgets/chat/call/participant.dart index 0d017c4..6b635c7 100644 --- a/lib/widgets/chat/call/participant.dart +++ b/lib/widgets/chat/call/participant.dart @@ -78,8 +78,6 @@ class RemoteParticipantWidget extends ParticipantWidget { } abstract class _ParticipantWidgetState extends State { - bool _visible = true; - VideoTrack? get _activeVideoTrack; TrackPublication? get _videoPublication; @@ -117,24 +115,19 @@ abstract class _ParticipantWidgetState extends Stat }); } - List extraWidgets(bool isScreenShare) => []; - @override Widget build(BuildContext ctx) { return Stack( children: [ - InkWell( - onTap: () => setState(() => _visible = !_visible), - child: _activeVideoTrack != null && !_activeVideoTrack!.muted - ? VideoTrackRenderer( - _activeVideoTrack!, - fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, - ) - : NoContentWidget( - userinfo: _userinfoMetadata, - isSpeaking: widget.participant.isSpeaking, - ), - ), + _activeVideoTrack != null && !_activeVideoTrack!.muted + ? VideoTrackRenderer( + _activeVideoTrack!, + fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + ) + : NoContentWidget( + userinfo: _userinfoMetadata, + isSpeaking: widget.participant.isSpeaking, + ), if (widget.showStatsLayer) Positioned( top: 30, @@ -149,10 +142,9 @@ abstract class _ParticipantWidgetState extends Stat crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - ...extraWidgets(widget.isScreenShare), ParticipantInfoWidget( title: widget.participant.name.isNotEmpty - ? '${widget.participant.name} (${widget.participant.identity})' + ? widget.participant.name : widget.participant.identity, audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, connectionQuality: widget.participant.connectionQuality, @@ -190,143 +182,4 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState widget.videoTrack; - - @override - List extraWidgets(bool isScreenShare) => [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Menu for RemoteTrackPublication - if (_firstAudioPublication != null && !isScreenShare) - RemoteTrackPublicationMenuWidget( - pub: _firstAudioPublication!, - icon: Icons.volume_up, - ), - if (_videoPublication != null) - RemoteTrackPublicationMenuWidget( - pub: _videoPublication!, - icon: isScreenShare ? Icons.monitor : Icons.videocam, - ), - if (_videoPublication != null) - RemoteTrackFPSMenuWidget( - pub: _videoPublication!, - icon: Icons.menu, - ), - if (_videoPublication != null) - RemoteTrackQualityMenuWidget( - pub: _videoPublication!, - icon: Icons.monitor_outlined, - ), - ], - ), - ]; -} - -class RemoteTrackPublicationMenuWidget extends StatelessWidget { - final IconData icon; - final RemoteTrackPublication pub; - - const RemoteTrackPublicationMenuWidget({ - required this.pub, - required this.icon, - super.key, - }); - - @override - Widget build(BuildContext context) => Material( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), - child: PopupMenuButton( - tooltip: 'Subscribe menu', - icon: Icon(icon, - color: { - TrackSubscriptionState.notAllowed: Colors.red, - TrackSubscriptionState.unsubscribed: Colors.grey, - TrackSubscriptionState.subscribed: Colors.green, - }[pub.subscriptionState]), - onSelected: (value) => value(), - itemBuilder: (BuildContext context) => >[ - if (pub.subscribed == false) - PopupMenuItem( - child: const Text('Subscribe'), - value: () => pub.subscribe(), - ) - else if (pub.subscribed == true) - PopupMenuItem( - child: const Text('Un-subscribe'), - value: () => pub.unsubscribe(), - ), - ], - ), - ); -} - -class RemoteTrackFPSMenuWidget extends StatelessWidget { - final IconData icon; - final RemoteTrackPublication pub; - - const RemoteTrackFPSMenuWidget({ - required this.pub, - required this.icon, - super.key, - }); - - @override - Widget build(BuildContext context) => Material( - color: Colors.black.withOpacity(0.3), - child: PopupMenuButton( - tooltip: 'Preferred FPS', - icon: Icon(icon, color: Colors.white), - onSelected: (value) => value(), - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - child: const Text('30'), - value: () => pub.setVideoFPS(30), - ), - PopupMenuItem( - child: const Text('15'), - value: () => pub.setVideoFPS(15), - ), - PopupMenuItem( - child: const Text('8'), - value: () => pub.setVideoFPS(8), - ), - ], - ), - ); -} - -class RemoteTrackQualityMenuWidget extends StatelessWidget { - final IconData icon; - final RemoteTrackPublication pub; - - const RemoteTrackQualityMenuWidget({ - required this.pub, - required this.icon, - super.key, - }); - - @override - Widget build(BuildContext context) => Material( - color: Colors.black.withOpacity(0.3), - child: PopupMenuButton( - tooltip: 'Preferred Quality', - icon: Icon(icon, color: Colors.white), - onSelected: (value) => value(), - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - child: const Text('HIGH'), - value: () => pub.setVideoQuality(VideoQuality.HIGH), - ), - PopupMenuItem( - child: const Text('MEDIUM'), - value: () => pub.setVideoQuality(VideoQuality.MEDIUM), - ), - PopupMenuItem( - child: const Text('LOW'), - value: () => pub.setVideoQuality(VideoQuality.LOW), - ), - ], - ), - ); } diff --git a/lib/widgets/chat/call/participant_menu.dart b/lib/widgets/chat/call/participant_menu.dart new file mode 100644 index 0000000..8805191 --- /dev/null +++ b/lib/widgets/chat/call/participant_menu.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.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 createState() => _ParticipantMenuState(); +} + +class _ParticipantMenuState extends State { + @override + RemoteTrackPublication? get _videoPublication => + widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull; + + @override + RemoteTrackPublication? get _firstAudioPublication => + widget.participant.audioTrackPublications.firstOrNull; + + @override + VideoTrack? get _activeVideoTrack => widget.videoTrack; + + 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( + AppLocalizations.of(context)!.action, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ), + Expanded( + child: ListView( + children: [ + if (_firstAudioPublication != null && !widget.isScreenShare) + ListTile( + leading: Icon( + Icons.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 + ? AppLocalizations.of(context)!.chatCallMute + : AppLocalizations.of(context)!.chatCallUnMute, + ), + onTap: () { + if (_firstAudioPublication!.subscribed) { + _firstAudioPublication!.unsubscribe(); + } else { + _firstAudioPublication!.subscribe(); + } + tookAction(); + }, + ), + if (_videoPublication != null) + ListTile( + leading: Icon( + widget.isScreenShare ? Icons.monitor : Icons.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 + ? AppLocalizations.of(context)!.chatCallVideoOff + : AppLocalizations.of(context)!.chatCallVideoOn, + ), + 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 ? Icons.check_box_outlined : Icons.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 ? Icons.check_box_outlined : Icons.check_box_outline_blank, + ), + title: Text('Set preferred quality to ${x.$1}'), + onTap: () { + _videoPublication!.setVideoQuality(x.$2); + tookAction(); + }, + ), + ), + ], + ), + ), + ], + ); + } +}