Solian/lib/widgets/chat/call/participant.dart
2024-04-27 13:12:26 +08:00

334 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/call.dart';
import 'package:solian/widgets/chat/call/no_video.dart';
import 'package:solian/widgets/chat/call/participant_info.dart';
import 'package:solian/widgets/chat/call/participant_stats.dart';
abstract class ParticipantWidget extends StatefulWidget {
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');
}
abstract final Participant participant;
abstract final VideoTrack? videoTrack;
abstract final bool isScreenShare;
abstract final bool showStatsLayer;
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 bool isScreenShare;
@override
final bool showStatsLayer;
const LocalParticipantWidget(
this.participant,
this.videoTrack,
this.isScreenShare,
this.showStatsLayer, {
super.key,
});
@override
State<StatefulWidget> 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, {
super.key,
});
@override
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
}
abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends State<T> {
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);
}
void onParticipantChanged() => setState(() {});
List<Widget> extraWidgets(bool isScreenShare) => [];
@override
Widget build(BuildContext ctx) => Container(
foregroundDecoration: BoxDecoration(
border: widget.participant.isSpeaking && !widget.isScreenShare
? Border.all(
width: 5,
color: Theme.of(context).colorScheme.primary,
)
: 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.isEncrypted,
),
],
),
),
],
),
);
}
class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> {
@override
LocalTrackPublication<LocalVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
@override
LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
}
class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemoteParticipantWidget> {
@override
RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication =>
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
@override
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
widget.participant.audioTrackPublications.firstOrNull;
@override
VideoTrack? get _activeVideoTrack => widget.videoTrack;
@override
List<Widget> extraWidgets(bool isScreenShare) => [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Menu for RemoteTrackPublication<RemoteAudioTrack>
if (_firstAudioPublication != null && !isScreenShare)
RemoteTrackPublicationMenuWidget(
pub: _firstAudioPublication!,
icon: Icons.volume_up,
),
// Menu for RemoteTrackPublication<RemoteVideoTrack>
if (_videoPublication != null)
RemoteTrackPublicationMenuWidget(
pub: _videoPublication!,
icon: isScreenShare ? Icons.monitor : Icons.videocam,
),
if (_videoPublication != null)
RemoteTrackFPSMenuWidget(
pub: _videoPublication!,
icon: Icons.menu,
),
if (_videoPublication != null)
RemoteTrackQualityMenuWidget(
pub: _videoPublication!,
icon: Icons.monitor_outlined,
),
],
),
];
}
class RemoteTrackPublicationMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackPublicationMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
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) => <PopupMenuEntry<Function>>[
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,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Preferred FPS',
icon: Icon(icon, color: Colors.white),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
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),
),
],
),
);
}
class RemoteTrackQualityMenuWidget extends StatelessWidget {
final IconData icon;
final RemoteTrackPublication pub;
const RemoteTrackQualityMenuWidget({
required this.pub,
required this.icon,
super.key,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.black.withOpacity(0.3),
child: PopupMenuButton<Function>(
tooltip: 'Preferred Quality',
icon: Icon(icon, color: Colors.white),
onSelected: (value) => value(),
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
PopupMenuItem(
child: const Text('HIGH'),
value: () => pub.setVideoQuality(VideoQuality.HIGH),
),
PopupMenuItem(
child: const Text('MEDIUM'),
value: () => pub.setVideoQuality(VideoQuality.MEDIUM),
),
PopupMenuItem(
child: const Text('LOW'),
value: () => pub.setVideoQuality(VideoQuality.LOW),
),
],
),
);
}