155 lines
		
	
	
		
			5.1 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			155 lines
		
	
	
		
			5.1 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'package:flutter/material.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/pods/chat/call.dart';
 | |
| import 'package:island/screens/account/profile.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 CallParticipantLive live;
 | |
|   final double size;
 | |
| 
 | |
|   const SpeakingRippleAvatar({super.key, required this.live, this.size = 96});
 | |
| 
 | |
|   @override
 | |
|   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<double>(
 | |
|         tween: Tween<double>(
 | |
|           begin: avatarRadius,
 | |
|           end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius,
 | |
|         ),
 | |
|         duration: const Duration(milliseconds: 250),
 | |
|         curve: Curves.easeOut,
 | |
|         builder: (context, animatedRadius, child) {
 | |
|           return Stack(
 | |
|             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),
 | |
|                   ),
 | |
|                 ),
 | |
|               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(),
 | |
|                       ),
 | |
|                 ),
 | |
|               ),
 | |
|               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),
 | |
|                   ),
 | |
|                 ),
 | |
|             ],
 | |
|           );
 | |
|         },
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class CallParticipantTile extends HookConsumerWidget {
 | |
|   final CallParticipantLive live;
 | |
| 
 | |
|   const CallParticipantTile({super.key, required this.live});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final hasVideo =
 | |
|         live.hasVideo &&
 | |
|         live.remoteParticipant.trackPublications.values
 | |
|             .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
 | |
|             .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,
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       );
 | |
|     } else {
 | |
|       return SpeakingRippleAvatar(size: 84, live: live);
 | |
|     }
 | |
|   }
 | |
| }
 |