import 'package:collection/collection.dart'; import 'package:eva_icons_flutter/eva_icons_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_example/theme.dart'; import 'no_video.dart'; import 'participant_info.dart'; import 'participant_stats.dart'; abstract class ParticipantWidget extends StatefulWidget { // Convenience method to return relevant widget for participant static ParticipantWidget widgetFor(ParticipantTrack participantTrack, {bool showStatsLayer = false}) { if (participantTrack.participant is LocalParticipant) { return LocalParticipantWidget( participantTrack.participant as LocalParticipant, participantTrack.videoTrack, participantTrack.isScreenShare, showStatsLayer); } else if (participantTrack.participant is RemoteParticipant) { return RemoteParticipantWidget( participantTrack.participant as RemoteParticipant, participantTrack.videoTrack, participantTrack.isScreenShare, showStatsLayer); } throw UnimplementedError('Unknown participant type'); } // Must be implemented by child class abstract final Participant participant; abstract final VideoTrack? videoTrack; abstract final bool isScreenShare; abstract final bool showStatsLayer; final VideoQuality quality; const ParticipantWidget({ this.quality = VideoQuality.MEDIUM, Key? key, }) : super(key: key); } class LocalParticipantWidget extends ParticipantWidget { @override final LocalParticipant participant; @override final VideoTrack? videoTrack; @override final bool isScreenShare; @override final bool showStatsLayer; const LocalParticipantWidget( this.participant, this.videoTrack, this.isScreenShare, this.showStatsLayer, { Key? key, }) : super(key: key); @override State createState() => _LocalParticipantWidgetState(); } class RemoteParticipantWidget extends ParticipantWidget { @override final RemoteParticipant participant; @override final VideoTrack? videoTrack; @override final bool isScreenShare; @override final bool showStatsLayer; const RemoteParticipantWidget( this.participant, this.videoTrack, this.isScreenShare, this.showStatsLayer, { Key? key, }) : super(key: key); @override State createState() => _RemoteParticipantWidgetState(); } abstract class _ParticipantWidgetState extends State { // bool _visible = true; VideoTrack? get activeVideoTrack; TrackPublication? get videoPublication; TrackPublication? get firstAudioPublication; @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); } // Notify Flutter that UI re-build is required, but we don't set anything here // since the updated values are computed properties. void _onParticipantChanged() => setState(() {}); // Widgets to show above the info bar List extraWidgets(bool isScreenShare) => []; @override Widget build(BuildContext ctx) => Container( foregroundDecoration: BoxDecoration( border: widget.participant.isSpeaking && !widget.isScreenShare ? Border.all( width: 5, color: LKColors.lkBlue, ) : null, ), decoration: BoxDecoration( color: Theme.of(ctx).cardColor, ), child: Stack( children: [ // Video InkWell( onTap: () => setState(() => _visible = !_visible), child: activeVideoTrack != null && !activeVideoTrack!.muted ? VideoTrackRenderer( activeVideoTrack!, fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, ) : const NoVideoWidget(), ), if (widget.showStatsLayer) Positioned( top: 30, right: 30, child: ParticipantStatsWidget( participant: widget.participant, )), // Bottom bar Align( alignment: Alignment.bottomCenter, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ ...extraWidgets(widget.isScreenShare), ParticipantInfoWidget( title: widget.participant.name.isNotEmpty ? '${widget.participant.name} (${widget.participant.identity})' : widget.participant.identity, audioAvailable: firstAudioPublication?.muted == false && firstAudioPublication?.subscribed == true, connectionQuality: widget.participant.connectionQuality, isScreenShare: widget.isScreenShare, enabledE2EE: widget.participant.firstTrackEncryptionType != EncryptionType.kNone, ), ], ), ), ], ), ); } class _LocalParticipantWidgetState extends _ParticipantWidgetState { @override LocalTrackPublication? get videoPublication => widget.participant.videoTracks .where((element) => element.sid == widget.videoTrack?.sid) .firstOrNull; @override LocalTrackPublication? get firstAudioPublication => widget.participant.audioTracks.firstOrNull; @override VideoTrack? get activeVideoTrack => widget.videoTrack; } class _RemoteParticipantWidgetState extends _ParticipantWidgetState { @override RemoteTrackPublication? get videoPublication => widget.participant.videoTracks .where((element) => element.sid == widget.videoTrack?.sid) .firstOrNull; @override RemoteTrackPublication? get firstAudioPublication => widget.participant.audioTracks.firstOrNull; @override VideoTrack? get activeVideoTrack => widget.videoTrack; @override List extraWidgets(bool isScreenShare) => [ Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.end, children: [ // Menu for RemoteTrackPublication if (firstAudioPublication != null && !isScreenShare) RemoteTrackPublicationMenuWidget( pub: firstAudioPublication!, icon: EvaIcons.volumeUp, ), // Menu for RemoteTrackPublication if (videoPublication != null) RemoteTrackPublicationMenuWidget( pub: videoPublication!, icon: isScreenShare ? EvaIcons.monitor : EvaIcons.video, ), if (videoPublication != null) RemoteTrackFPSMenuWidget( pub: videoPublication!, icon: EvaIcons.options2, ), ], ), ]; } class RemoteTrackPublicationMenuWidget extends StatelessWidget { final IconData icon; final RemoteTrackPublication pub; const RemoteTrackPublicationMenuWidget({ required this.pub, required this.icon, Key? key, }) : super(key: key); @override Widget build(BuildContext context) => Material( color: Colors.black.withOpacity(0.3), child: PopupMenuButton( tooltip: 'Subscribe menu', icon: Icon(icon, color: { TrackSubscriptionState.notAllowed: Colors.red, TrackSubscriptionState.unsubscribed: Colors.grey, TrackSubscriptionState.subscribed: Colors.green, }[pub.subscriptionState]), onSelected: (value) => value(), itemBuilder: (BuildContext context) => >[ // Subscribe/Unsubscribe if (pub.subscribed == false) PopupMenuItem( child: const Text('Subscribe'), value: () => pub.subscribe(), ) else if (pub.subscribed == true) PopupMenuItem( child: const Text('Un-subscribe'), value: () => pub.unsubscribe(), ), ], ), ); } class RemoteTrackFPSMenuWidget extends StatelessWidget { final IconData icon; final RemoteTrackPublication pub; const RemoteTrackFPSMenuWidget({ required this.pub, required this.icon, Key? key, }) : super(key: key); @override Widget build(BuildContext context) => Material( color: Colors.black.withOpacity(0.3), child: PopupMenuButton( tooltip: 'PreferredFPS', icon: Icon(icon, color: Colors.white), onSelected: (value) => value(), itemBuilder: (BuildContext context) => >[ PopupMenuItem( child: const Text('30'), value: () => pub.setVideoFPS(30), ), PopupMenuItem( child: const Text('15'), value: () => pub.setVideoFPS(15), ), PopupMenuItem( child: const Text('8'), value: () => pub.setVideoFPS(8), ), ], ), ); }