import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:gap/gap.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/types/account.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/chat/call/call_no_content.dart'; import 'package:surface/widgets/chat/call/call_participant_info.dart'; import 'package:surface/widgets/chat/call/call_participant_menu.dart'; import 'package:surface/widgets/chat/call/call_participant_stats.dart'; abstract class ParticipantWidget extends StatefulWidget { static ParticipantWidget widgetFor( ParticipantTrack participantTrack, { double? avatarSize, EdgeInsets? padding, bool showStatsLayer = false, bool isList = false, }) { if (participantTrack.participant is LocalParticipant) { return LocalParticipantWidget( participantTrack.participant as LocalParticipant, participantTrack.videoTrack, avatarSize, participantTrack.isScreenShare, showStatsLayer, isList, padding, ); } else if (participantTrack.participant is RemoteParticipant) { return RemoteParticipantWidget( participantTrack.participant as RemoteParticipant, participantTrack.videoTrack, avatarSize, participantTrack.isScreenShare, showStatsLayer, isList, padding, ); } throw UnimplementedError('Unknown participant type'); } abstract final Participant participant; abstract final VideoTrack? videoTrack; abstract final bool isScreenShare; abstract final double? avatarSize; abstract final bool showStatsLayer; abstract final bool isList; abstract final EdgeInsets? padding; final VideoQuality quality; const ParticipantWidget({ super.key, this.quality = VideoQuality.MEDIUM, }); } class LocalParticipantWidget extends ParticipantWidget { @override final LocalParticipant participant; @override final VideoTrack? videoTrack; @override final double? avatarSize; @override final bool isScreenShare; @override final bool showStatsLayer; @override final bool isList; @override final EdgeInsets? padding; const LocalParticipantWidget( this.participant, this.videoTrack, this.avatarSize, this.isScreenShare, this.showStatsLayer, this.isList, this.padding, { super.key, }); @override State createState() => _LocalParticipantWidgetState(); } class RemoteParticipantWidget extends ParticipantWidget { @override final RemoteParticipant participant; @override final VideoTrack? videoTrack; @override final double? avatarSize; @override final bool isScreenShare; @override final bool showStatsLayer; @override final bool isList; @override final EdgeInsets? padding; const RemoteParticipantWidget( this.participant, this.videoTrack, this.avatarSize, this.isScreenShare, this.showStatsLayer, this.isList, this.padding, { super.key, }); @override State createState() => _RemoteParticipantWidgetState(); } abstract class _ParticipantWidgetState extends State { VideoTrack? get _activeVideoTrack; TrackPublication? get _firstAudioPublication; SnAccount? _userinfoMetadata; @override void initState() { super.initState(); widget.participant.addListener(onParticipantChanged); onParticipantChanged(); } @override void dispose() { widget.participant.removeListener(onParticipantChanged); super.dispose(); } @override void didUpdateWidget(covariant T oldWidget) { oldWidget.participant.removeListener(onParticipantChanged); widget.participant.addListener(onParticipantChanged); onParticipantChanged(); super.didUpdateWidget(oldWidget); } void onParticipantChanged() { setState(() { if (widget.participant.metadata != null) { _userinfoMetadata = SnAccount.fromJson( jsonDecode(widget.participant.metadata!), ); } }); } @override Widget build(BuildContext context) { if (widget.isList) { return Padding( padding: widget.padding ?? EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ SizedBox( width: (widget.avatarSize ?? 32) * 2, height: (widget.avatarSize ?? 32) * 2, child: Center( child: NoContentWidget( userinfo: _userinfoMetadata, avatarSize: widget.avatarSize, isSpeaking: widget.participant.isSpeaking, ), ), ), const Gap(8), Expanded( child: SizedBox( height: (widget.avatarSize ?? 32) * 2, child: ParticipantInfoWidget( isList: true, title: widget.participant.name.isNotEmpty ? widget.participant.name : widget.participant.identity, audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, connectionQuality: widget.participant.connectionQuality, isScreenShare: widget.isScreenShare, ), ), ), ], ), if (_activeVideoTrack != null && !_activeVideoTrack!.muted) ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 16 / 9, child: Material( borderRadius: const BorderRadius.all(Radius.circular(8)), color: Theme.of(context) .colorScheme .surfaceContainer .withOpacity(0.75), child: VideoTrackRenderer( _activeVideoTrack!, fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, ), ), ).padding(top: 8), ), ], ), ); } return Stack( children: [ if (_activeVideoTrack != null && !_activeVideoTrack!.muted) VideoTrackRenderer( _activeVideoTrack!, fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, ) else Center( child: NoContentWidget( userinfo: _userinfoMetadata, avatarSize: widget.avatarSize, isSpeaking: widget.participant.isSpeaking, ), ), if (widget.showStatsLayer) Positioned( top: 30, right: 30, child: ParticipantStatsWidget(participant: widget.participant), ), Align( alignment: Alignment.bottomCenter, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ ParticipantInfoWidget( title: widget.participant.name.isNotEmpty ? widget.participant.name : widget.participant.identity, audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, connectionQuality: widget.participant.connectionQuality, isScreenShare: widget.isScreenShare, ), ], ), ), ], ); } } class _LocalParticipantWidgetState extends _ParticipantWidgetState { @override LocalTrackPublication? get _firstAudioPublication => widget.participant.audioTrackPublications.firstOrNull; @override VideoTrack? get _activeVideoTrack => widget.videoTrack; } class _RemoteParticipantWidgetState extends _ParticipantWidgetState { @override RemoteTrackPublication? get _firstAudioPublication => widget.participant.audioTrackPublications.firstOrNull; @override VideoTrack? get _activeVideoTrack => widget.videoTrack; } class InteractiveParticipantWidget extends StatelessWidget { final double? avatarSize; final bool isList; final ParticipantTrack participant; final Function? onTap; final EdgeInsets? padding; const InteractiveParticipantWidget({ super.key, this.avatarSize, this.isList = false, this.padding, required this.participant, this.onTap, }); @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap != null ? () { onTap?.call(); } : null, onLongPress: () { if (participant.participant is LocalParticipant) return; showModalBottomSheet( context: context, builder: (context) => ParticipantMenu( participant: participant.participant as RemoteParticipant, videoTrack: participant.videoTrack, isScreenShare: participant.isScreenShare, ), ); }, child: Container( child: ParticipantWidget.widgetFor( participant, avatarSize: avatarSize, isList: isList, padding: padding, ), ), ), ); } }