import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/chat/call.dart'; import 'package:island/widgets/chat/call_participant_card.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; class SpeakingRippleAvatar extends HookConsumerWidget { final CallParticipantLive live; final double size; const SpeakingRippleAvatar({super.key, required this.live, this.size = 96}); @override Widget build(BuildContext context, WidgetRef ref) { final avatarRadius = size / 2; final clampedLevel = live.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.isSpeaking ? rippleRadius : avatarRadius, ), duration: const Duration(milliseconds: 250), curve: Curves.easeOut, builder: (context, animatedRadius, child) { return Stack( alignment: Alignment.center, children: [ if (live.isSpeaking) Container( width: animatedRadius * 2, height: animatedRadius * 2, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), ), ), Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration(shape: BoxShape.circle), child: CallParticipantGestureDetector( participant: live, child: ProfilePictureWidget( file: live.remoteParticipant.userinfo.profile.picture, radius: size / 2, ), ), ), if (live.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), ), ), ], ); }, ), ); } } class CallParticipantTile extends StatefulWidget { final CallParticipantLive live; const CallParticipantTile({super.key, required this.live}); @override State createState() => _CallParticipantTileState(); } class _CallParticipantTileState extends State { RTCVideoRenderer? _renderer; @override void initState() { super.initState(); _initRenderer(); } @override void didUpdateWidget(CallParticipantTile oldWidget) { super.didUpdateWidget(oldWidget); // Update renderer source when the stream changes if (_renderer != null && widget.live.remoteParticipant.remoteStream != oldWidget.live.remoteParticipant.remoteStream) { _renderer!.srcObject = widget.live.remoteParticipant.remoteStream; } } Future _initRenderer() async { _renderer = RTCVideoRenderer(); await _renderer!.initialize(); _renderer!.srcObject = widget.live.remoteParticipant.remoteStream; if (mounted) { setState(() {}); } } @override void dispose() { _renderer?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.live.hasVideo && widget.live.remoteParticipant.remoteStream != null && _renderer != null) { return Stack( fit: StackFit.loose, children: [ AspectRatio(aspectRatio: 16 / 9, child: RTCVideoView(_renderer!)), Positioned( left: 8, right: 8, bottom: 8, child: Text( '@${widget.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, ), ], ), ), ), ], ); } else { return SpeakingRippleAvatar(size: 84, live: widget.live); } } }