diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 218eb44..83ed70c 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -726,5 +726,6 @@ "reactionHeart": "Heart", "selectMicrophone": "Select Microphone", "selectCamera": "Select Camera", - "switchedTo": "Switched to {}" + "switchedTo": "Switched to {}", + "connecting": "Connecting" } diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index d67165c..0e87349 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -49,7 +49,7 @@ class CallScreen extends HookConsumerWidget { Text( callState.isConnected ? formatDuration(callState.duration) - : 'Connecting', + : 'connecting', style: const TextStyle(fontSize: 14), ), ], @@ -61,13 +61,7 @@ class CallScreen extends HookConsumerWidget { spacing: 4, children: [ for (final live in callNotifier.participants) - SpeakingRippleAvatar( - isSpeaking: live.isSpeaking, - isMuted: live.isMuted, - audioLevel: live.remoteParticipant.audioLevel, - identity: live.participant.identity, - size: 30, - ), + SpeakingRippleAvatar(live: live, size: 30), const Gap(8), ], ), @@ -131,11 +125,7 @@ class CallScreen extends HookConsumerWidget { children: [ for (final live in participants) SpeakingRippleAvatar( - isSpeaking: live.isSpeaking, - isMuted: live.isMuted, - audioLevel: - live.remoteParticipant.audioLevel, - identity: live.participant.identity, + live: live, size: 72, ).padding(horizontal: 4), ], diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index 8d9f319..46846c8 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -338,11 +338,7 @@ class CallOverlayBar extends HookConsumerWidget { height: 40, child: SpeakingRippleAvatar( - isSpeaking: lastSpeaker.isSpeaking, - isMuted: lastSpeaker.isMuted, - audioLevel: - lastSpeaker.remoteParticipant.audioLevel, - identity: lastSpeaker.participant.identity, + live: lastSpeaker, size: 36, ).center(), ); diff --git a/lib/widgets/chat/call_participant_card.dart b/lib/widgets/chat/call_participant_card.dart new file mode 100644 index 0000000..98d88e5 --- /dev/null +++ b/lib/widgets/chat/call_participant_card.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_popup_card/flutter_popup_card.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/call.dart'; +import 'package:island/widgets/account/account_nameplate.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class CallParticipantCard extends HookConsumerWidget { + final CallParticipantLive live; + const CallParticipantCard({super.key, required this.live}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final width = + math.min(MediaQuery.of(context).size.width - 80, 360).toDouble(); + return PopupCard( + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + child: SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Column( + children: [ + Row( + children: [ + const Icon(Symbols.wifi, size: 16), + const Gap(8), + Text(switch (live.remoteParticipant.connectionQuality) { + ConnectionQuality.excellent => 'Excellent', + ConnectionQuality.good => 'Good', + ConnectionQuality.poor => 'Bad', + ConnectionQuality.lost => 'Lost', + _ => 'Connecting', + }), + ], + ), + ], + ).padding(horizontal: 20, top: 16), + AccountNameplate( + name: live.participant.identity, + isOutlined: false, + ), + ], + ), + ), + ); + } +} + +class CallParticipantGestureDetector extends StatelessWidget { + final CallParticipantLive participant; + final Widget child; + const CallParticipantGestureDetector({ + super.key, + required this.participant, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + child: child, + onTapDown: (details) { + showCallParticipantCard( + context, + participant, + offset: details.localPosition, + ); + }, + ); + } +} + +Future showCallParticipantCard( + BuildContext context, + CallParticipantLive participant, { + Offset? offset, +}) async { + await showPopupCard( + offset: offset ?? Offset.zero, + context: context, + builder: (context) => CallParticipantCard(live: participant), + alignment: Alignment.center, + dimBackground: true, + ); +} diff --git a/lib/widgets/chat/call_participant_tile.dart b/lib/widgets/chat/call_participant_tile.dart index 33fdca7..d396853 100644 --- a/lib/widgets/chat/call_participant_tile.dart +++ b/lib/widgets/chat/call_participant_tile.dart @@ -2,34 +2,24 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/call.dart'; import 'package:island/screens/account/profile.dart'; -import 'package:island/widgets/account/account_pfc.dart'; +import 'package:island/widgets/chat/call_participant_card.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; class SpeakingRippleAvatar extends HookConsumerWidget { - final bool isSpeaking; - final bool isMuted; - final double audioLevel; - final String identity; + final CallParticipantLive live; final double size; - const SpeakingRippleAvatar({ - super.key, - required this.isSpeaking, - required this.isMuted, - required this.audioLevel, - required this.identity, - this.size = 96, - }); + const SpeakingRippleAvatar({super.key, required this.live, this.size = 96}); @override Widget build(BuildContext context, WidgetRef ref) { - final account = ref.watch(accountProvider(identity)); + final account = ref.watch(accountProvider(live.participant.identity)); final avatarRadius = size / 2; - final clampedLevel = audioLevel.clamp(0.0, 1.0); + final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0); final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); return SizedBox( width: size + 8, @@ -37,7 +27,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget { child: TweenAnimationBuilder( tween: Tween( begin: avatarRadius, - end: isSpeaking ? rippleRadius : avatarRadius, + end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius, ), duration: const Duration(milliseconds: 250), curve: Curves.easeOut, @@ -45,7 +35,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget { return Stack( alignment: Alignment.center, children: [ - if (isSpeaking) + if (live.remoteParticipant.isSpeaking) Container( width: animatedRadius * 2, height: animatedRadius * 2, @@ -61,8 +51,8 @@ class SpeakingRippleAvatar extends HookConsumerWidget { decoration: BoxDecoration(shape: BoxShape.circle), child: account.when( data: - (value) => AccountPfcGestureDetector( - uname: identity, + (value) => CallParticipantGestureDetector( + participant: live, child: ProfilePictureWidget( file: value.profile.picture, radius: size / 2, @@ -80,7 +70,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget { ), ), ), - if (isMuted) + if (live.remoteParticipant.isMuted) Positioned( bottom: 4, right: 4, @@ -118,7 +108,6 @@ class CallParticipantTile extends HookConsumerWidget { live.remoteParticipant.trackPublications.values .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO) .isNotEmpty; - final audioLevel = live.remoteParticipant.audioLevel; if (hasVideo) { return Stack( @@ -159,13 +148,7 @@ class CallParticipantTile extends HookConsumerWidget { ], ); } else { - return SpeakingRippleAvatar( - isSpeaking: live.isSpeaking, - isMuted: live.isMuted, - audioLevel: audioLevel, - identity: live.participant.identity, - size: 84, - ); + return SpeakingRippleAvatar(size: 84, live: live); } } }