diff --git a/lib/pods/chat/call.dart b/lib/pods/chat/call.dart index 1aa5b59a..773c4683 100644 --- a/lib/pods/chat/call.dart +++ b/lib/pods/chat/call.dart @@ -217,7 +217,9 @@ class CallNotifier extends _$CallNotifier { Future joinRoom(SnChatRoom room) async { var roomId = room.id; - if (_roomId == roomId && _room != null) { + if (_roomId == roomId && + _room != null && + _room?.connectionState == lk.ConnectionState.connected) { talker.info('[Call] Call skipped. Already has data'); return; } else if (_room != null) { diff --git a/lib/widgets/chat/call_content.dart b/lib/widgets/chat/call_content.dart index 47dc2acf..84a3ed29 100644 --- a/lib/widgets/chat/call_content.dart +++ b/lib/widgets/chat/call_content.dart @@ -67,7 +67,9 @@ class CallContent extends HookConsumerWidget { if (mainSpeakers.isEmpty && participants.isNotEmpty) { mainSpeakers.add(participants.first); } - return Column( + return Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, children: [ for (final speaker in mainSpeakers) Expanded(child: CallParticipantTile(live: speaker)), diff --git a/lib/widgets/chat/call_participant_tile.dart b/lib/widgets/chat/call_participant_tile.dart index 75f67278..161c444a 100644 --- a/lib/widgets/chat/call_participant_tile.dart +++ b/lib/widgets/chat/call_participant_tile.dart @@ -8,6 +8,59 @@ import 'package:livekit_client/livekit_client.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; +class SpeakingRipple extends StatelessWidget { + final double size; + final double audioLevel; + final bool isSpeaking; + final Widget child; + + const SpeakingRipple({ + super.key, + required this.size, + required this.audioLevel, + required this.isSpeaking, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final avatarRadius = size / 2; + final clampedLevel = audioLevel.clamp(0.0, 1.0); + final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); + + return SizedBox( + width: size + 8, + height: size + 8, + child: TweenAnimationBuilder( + tween: Tween( + begin: avatarRadius, + end: isSpeaking ? rippleRadius : avatarRadius, + ), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + builder: (context, animatedRadius, child) { + return Stack( + alignment: Alignment.center, + children: [ + if (isSpeaking) + Container( + width: animatedRadius * 2, + height: animatedRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), + ), + ), + child!, + ], + ); + }, + child: SizedBox(width: size, height: size, child: child), + ), + ); + } +} + class SpeakingRippleAvatar extends HookConsumerWidget { final CallParticipantLive live; final double size; @@ -18,79 +71,58 @@ class SpeakingRippleAvatar extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final account = ref.watch(accountProvider(live.participant.identity)); - final avatarRadius = size / 2; - final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0); - final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); - return SizedBox( - width: size + 8, - height: size + 8, - child: TweenAnimationBuilder( - tween: Tween( - begin: avatarRadius, - end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius, - ), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - builder: (context, animatedRadius, child) { - return Stack( + return SpeakingRipple( + size: size, + audioLevel: live.remoteParticipant.audioLevel, + isSpeaking: live.remoteParticipant.isSpeaking, + child: Stack( + children: [ + Container( + width: size, + height: size, alignment: Alignment.center, - children: [ - if (live.remoteParticipant.isSpeaking) - Container( - width: animatedRadius * 2, - height: animatedRadius * 2, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), + decoration: const BoxDecoration(shape: BoxShape.circle), + child: account.when( + data: + (value) => CallParticipantGestureDetector( + participant: live, + child: ProfilePictureWidget( + file: value.profile.picture, + radius: size / 2, + ), ), + error: + (_, _) => CircleAvatar( + radius: size / 2, + child: const Icon(Symbols.person_remove), + ), + loading: + () => CircleAvatar( + radius: size / 2, + child: CircularProgressIndicator(), + ), + ), + ), + if (live.remoteParticipant.isMuted) + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 2), ), - Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration(shape: BoxShape.circle), - child: account.when( - data: - (value) => CallParticipantGestureDetector( - participant: live, - child: ProfilePictureWidget( - file: value.profile.picture, - radius: size / 2, - ), - ), - error: - (_, _) => CircleAvatar( - radius: size / 2, - child: const Icon(Symbols.person_remove), - ), - loading: - () => CircleAvatar( - radius: size / 2, - child: CircularProgressIndicator(), - ), + child: const Icon( + Symbols.mic_off, + size: 14, + color: Colors.white, ), ), - if (live.remoteParticipant.isMuted) - Positioned( - bottom: 4, - right: 4, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - child: const Icon( - Symbols.mic_off, - size: 14, - fill: 1, - ).padding(left: 1.5, top: 1.5), - ), - ), - ], - ); - }, + ), + ], ), ); } @@ -103,6 +135,8 @@ class CallParticipantTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final userInfo = ref.watch(accountProvider(live.participant.name)); + final hasVideo = live.hasVideo && live.remoteParticipant.trackPublications.values @@ -110,42 +144,92 @@ class CallParticipantTile extends HookConsumerWidget { .isNotEmpty; if (hasVideo) { - return Stack( - fit: StackFit.loose, - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: VideoTrackRenderer( - live.remoteParticipant.trackPublications.values - .where((track) => track.kind == TrackType.VIDEO) - .first - .track - as VideoTrack, - renderMode: VideoRenderMode.platformView, - ), - ), - Positioned( - left: 8, - right: 8, - bottom: 8, - child: Text( - '@${live.participant.name}', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - color: Colors.white, - shadows: [ - BoxShadow( - color: Colors.black54, - offset: Offset(1, 1), - spreadRadius: 8, - blurRadius: 8, - ), - ], + return Padding( + padding: const EdgeInsets.all(8), + child: LayoutBuilder( + builder: (context, constraints) { + // Use the smaller dimension to determine the "size" for the ripple calculation + // effectively making the ripple relative to the tile size. + // However, for a rectangular video, we might want a different approach. + // The user asked for "speaking ripple to the video as well". + // If we use the extracted SpeakingRipple, it expects a size and assumes a circle. + // We need to adapt it or create a rectangular version. + // Given the "image" likely shows a rectangular video with rounded corners, + // let's create a specific wrapper for the video tile that adds a border/glow when speaking. + + final isSpeaking = live.remoteParticipant.isSpeaking; + final audioLevel = live.remoteParticipant.audioLevel; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + isSpeaking + ? Colors.green.withOpacity( + 0.5 + 0.5 * audioLevel.clamp(0.0, 1.0), + ) + : Theme.of(context).colorScheme.outlineVariant, + width: isSpeaking ? 4 : 1, + ), ), - ), - ), - ], + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + VideoTrackRenderer( + live.remoteParticipant.trackPublications.values + .where((track) => track.kind == TrackType.VIDEO) + .first + .track + as VideoTrack, + renderMode: VideoRenderMode.platformView, + ), + Positioned( + left: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (live.remoteParticipant.isMuted) + const Icon( + Symbols.mic_off, + size: 14, + color: Colors.redAccent, + ).padding(right: 4), + Text( + userInfo.value?.nick ?? live.participant.name, + style: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), ); } else { return SpeakingRippleAvatar(size: 84, live: live);