Call join

This commit is contained in:
2024-04-27 13:12:26 +08:00
parent 15c8c0fe8f
commit 7ac5c651aa
24 changed files with 1801 additions and 34 deletions

View File

@ -0,0 +1,379 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_background/flutter_background.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/widgets/chat/call/exts.dart';
class ControlsWidget extends StatefulWidget {
final Room room;
final LocalParticipant participant;
const ControlsWidget(
this.room,
this.participant, {
super.key,
});
@override
State<StatefulWidget> createState() => _ControlsWidgetState();
}
class _ControlsWidgetState extends State<ControlsWidget> {
CameraPosition position = CameraPosition.front;
List<MediaDevice>? _audioInputs;
List<MediaDevice>? _audioOutputs;
List<MediaDevice>? _videoInputs;
StreamSubscription? _subscription;
bool _speakerphoneOn = false;
@override
void initState() {
super.initState();
participant.addListener(onChange);
_subscription = Hardware.instance.onDeviceChange.stream.listen((List<MediaDevice> devices) {
revertDevices(devices);
});
Hardware.instance.enumerateDevices().then(revertDevices);
_speakerphoneOn = Hardware.instance.speakerOn ?? false;
}
@override
void dispose() {
_subscription?.cancel();
participant.removeListener(onChange);
super.dispose();
}
LocalParticipant get participant => widget.participant;
void revertDevices(List<MediaDevice> devices) async {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
setState(() {});
}
void onChange() => setState(() {});
void unpublishAll() async {
final result = await context.showUnPublishDialog();
if (result == true) await participant.unpublishAllTracks();
}
bool get isMuted => participant.isMuted;
void disableAudio() async {
await participant.setMicrophoneEnabled(false);
}
Future<void> enableAudio() async {
await participant.setMicrophoneEnabled(true);
}
void disableVideo() async {
await participant.setCameraEnabled(false);
}
void enableVideo() async {
await participant.setCameraEnabled(true);
}
void selectAudioOutput(MediaDevice device) async {
await widget.room.setAudioOutputDevice(device);
setState(() {});
}
void selectAudioInput(MediaDevice device) async {
await widget.room.setAudioInputDevice(device);
setState(() {});
}
void selectVideoInput(MediaDevice device) async {
await widget.room.setVideoInputDevice(device);
setState(() {});
}
void setSpeakerphoneOn() {
_speakerphoneOn = !_speakerphoneOn;
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
setState(() {});
}
void toggleCamera() async {
//
final track = participant.videoTrackPublications.firstOrNull?.track;
if (track == null) return;
try {
final newPosition = position.switched();
await track.setCameraPosition(newPosition);
setState(() {
position = newPosition;
});
} catch (error) {
return;
}
}
void enableScreenShare() async {
if (lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => ScreenSelectDialog(),
);
if (source == null) {
return;
}
var track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions(
sourceId: source.id,
maxFrameRate: 15.0,
),
);
await participant.publishVideoTrack(track);
} catch (e) {
final message = e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Something went wrong... $message'),
));
}
return;
}
if (lkPlatformIs(PlatformType.android)) {
requestBackgroundPermission([bool isRetry = false]) async {
try {
bool hasPermissions = await FlutterBackground.hasPermissions;
if (!isRetry) {
const androidConfig = FlutterBackgroundAndroidConfig(
notificationTitle: 'Screen Sharing',
notificationText: 'A Solar Messager\'s Call is sharing your screen',
notificationImportance: AndroidNotificationImportance.Default,
notificationIcon: AndroidResource(name: 'launcher_icon', defType: 'mipmap'),
);
hasPermissions = await FlutterBackground.initialize(androidConfig: androidConfig);
}
if (hasPermissions && !FlutterBackground.isBackgroundExecutionEnabled) {
await FlutterBackground.enableBackgroundExecution();
}
} catch (e) {
if (!isRetry) {
return await Future<void>.delayed(const Duration(seconds: 1), () => requestBackgroundPermission(true));
}
}
}
await requestBackgroundPermission();
}
if (lkPlatformIs(PlatformType.iOS)) {
var track = await LocalVideoTrack.createScreenShareTrack(
const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
maxFrameRate: 30.0,
),
);
await participant.publishVideoTrack(track);
return;
}
if (lkPlatformIsWebMobile()) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Screen share is not supported mobile platform.'),
));
return;
}
await participant.setScreenShareEnabled(true, captureScreenAudio: true);
}
void disableScreenShare() async {
await participant.setScreenShareEnabled(false);
if (lkPlatformIs(PlatformType.android)) {
// Android specific
try {
await FlutterBackground.disableBackgroundExecution();
} catch (_) {}
}
}
void onTapUpdateSubscribePermission() async {
final result = await context.showSubscribePermissionDialog();
if (result != null) {
try {
widget.room.localParticipant?.setTrackSubscriptionPermissions(
allParticipantsAllowed: result,
);
} catch (e) {
final message = e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Something went wrong... $message'),
));
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 15,
horizontal: 15,
),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 5,
runSpacing: 5,
children: [
IconButton(
onPressed: unpublishAll,
icon: const Icon(Icons.cancel),
tooltip: 'Unpublish all',
),
if (participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android))
IconButton(
onPressed: disableAudio,
icon: const Icon(Icons.mic),
tooltip: 'mute audio',
)
else
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.settings_voice),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: isMuted ? enableAudio : disableAudio,
child: const ListTile(
leading: Icon(Icons.mic_off),
title: Text('Mute Microphone'),
),
),
if (_audioInputs != null)
..._audioInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedAudioInputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectAudioInput(device),
);
})
];
},
)
else
IconButton(
onPressed: enableAudio,
icon: const Icon(Icons.mic_off),
tooltip: 'un-mute audio',
),
if (!lkPlatformIs(PlatformType.iOS))
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.volume_up),
itemBuilder: (BuildContext context) {
return [
const PopupMenuItem<MediaDevice>(
value: null,
child: ListTile(
leading: Icon(Icons.speaker),
title: Text('Select Audio Output'),
),
),
if (_audioOutputs != null)
..._audioOutputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectAudioOutput(device),
);
})
];
},
),
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
IconButton(
disabledColor: Colors.grey,
onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null,
icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android),
tooltip: 'Switch SpeakerPhone',
),
if (participant.isCameraEnabled())
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.videocam_sharp),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: disableVideo,
child: const ListTile(
leading: Icon(
Icons.videocam_off,
color: Colors.white,
),
title: Text('Disable Camera'),
),
),
if (_videoInputs != null)
..._videoInputs!.map((device) {
return PopupMenuItem<MediaDevice>(
value: device,
child: ListTile(
leading: (device.deviceId == widget.room.selectedVideoInputDeviceId)
? const Icon(Icons.check_box_outlined)
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectVideoInput(device),
);
})
];
},
)
else
IconButton(
onPressed: enableVideo,
icon: const Icon(Icons.videocam_off),
tooltip: 'un-mute video',
),
IconButton(
icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front),
onPressed: () => toggleCamera(),
tooltip: 'toggle camera',
),
if (participant.isScreenShareEnabled())
IconButton(
icon: const Icon(Icons.monitor_outlined),
onPressed: () => disableScreenShare(),
tooltip: 'unshare screen (experimental)',
)
else
IconButton(
icon: const Icon(Icons.monitor),
onPressed: () => enableScreenShare(),
tooltip: 'share screen (experimental)',
),
IconButton(
onPressed: onTapUpdateSubscribePermission,
icon: const Icon(Icons.settings),
tooltip: 'Subscribe permission',
),
],
),
);
}
}

View File

@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
extension SolianCallExt on BuildContext {
Future<bool?> showPublishDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Publish'),
content: const Text('Would you like to publish your Camera & Mic ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Play Audio'),
content: const Text(
'You need to manually activate audio PlayBack for iOS Safari !'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Ignore'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Play Audio'),
),
],
),
);
Future<bool?> showUnPublishDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('UnPublish'),
content:
const Text('Would you like to un-publish your Camera & Mic ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
Future<void> showErrorDialog(dynamic exception) => showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Error'),
content: Text(exception.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
)
],
),
);
Future<bool?> showDisconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure to disconnect?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Disconnect'),
),
],
),
);
Future<bool?> showReconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('This will force a reconnection'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Reconnect'),
),
],
),
);
Future<void> showReconnectSuccessDialog() => showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('Reconnection was successful.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
Future<bool?> showSendDataDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Send data'),
content: const Text(
'This will send a sample data to all participants in the room'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Send'),
),
],
),
);
Future<bool?> showDataReceivedDialog(String data) => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Received data'),
content: Text(data),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('OK'),
),
],
),
);
Future<bool?> showRecordingStatusChangedDialog(bool isActiveRecording) =>
showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Room recording reminder'),
content: Text(isActiveRecording
? 'Room recording is active.'
: 'Room recording is stoped.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('OK'),
),
],
),
);
Future<bool?> showSubscribePermissionDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Allow subscription'),
content: const Text(
'Allow all participants to subscribe tracks published by local participant?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
}
enum SimulateScenarioResult {
signalReconnect,
fullReconnect,
speakerUpdate,
nodeFailure,
migration,
serverLeave,
switchCandidate,
e2eeKeyRatchet,
participantName,
participantMetadata,
clear,
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
class NoVideoWidget extends StatelessWidget {
const NoVideoWidget({super.key});
@override
Widget build(BuildContext context) => Container(
alignment: Alignment.center,
child: LayoutBuilder(
builder: (ctx, constraints) => Icon(
Icons.videocam_off_outlined,
color: Theme.of(context).colorScheme.primary,
size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3,
),
),
);
}

View File

@ -0,0 +1,333 @@
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),
),
],
),
);
}

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
class ParticipantInfoWidget extends StatelessWidget {
final String? title;
final bool audioAvailable;
final ConnectionQuality connectionQuality;
final bool isScreenShare;
final bool enabledE2EE;
const ParticipantInfoWidget({
super.key,
this.title,
this.audioAvailable = true,
this.connectionQuality = ConnectionQuality.unknown,
this.isScreenShare = false,
this.enabledE2EE = false,
});
@override
Widget build(BuildContext context) => Container(
color: Colors.black.withOpacity(0.3),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (title != null)
Flexible(
child: Text(
title!,
overflow: TextOverflow.ellipsis,
),
),
isScreenShare
? const Padding(
padding: EdgeInsets.only(left: 5),
child: Icon(
Icons.monitor,
color: Colors.white,
size: 16,
),
)
: Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
audioAvailable ? Icons.mic : Icons.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
),
if (connectionQuality != ConnectionQuality.unknown)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
connectionQuality == ConnectionQuality.poor ? Icons.wifi_off_outlined : Icons.wifi,
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
),
),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
enabledE2EE ? Icons.lock : Icons.lock_open,
color: enabledE2EE ? Colors.green : Colors.red,
size: 16,
),
),
],
),
);
}

View File

@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/call.dart';
class ParticipantStatsWidget extends StatefulWidget {
const ParticipantStatsWidget({super.key, required this.participant});
final Participant participant;
@override
State<StatefulWidget> createState() => _ParticipantStatsWidgetState();
}
class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
List<EventsListener<TrackEvent>> listeners = [];
ParticipantStatsType statsType = ParticipantStatsType.unknown;
Map<String, String> stats = {};
void _setUpListener(Track track) {
var listener = track.createListener();
listeners.add(listener);
if (track is LocalVideoTrack) {
statsType = ParticipantStatsType.localVideoSender;
listener.on<VideoSenderStatsEvent>((event) {
setState(() {
stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs';
event.stats.forEach((key, value) {
stats['layer-$key'] =
'${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps';
});
var firstStats = event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
if (firstStats != null) {
stats['encoder'] = firstStats.encoderImplementation ?? '';
stats['video codec'] = '${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
stats['qualityLimitationReason'] = firstStats.qualityLimitationReason ?? '';
}
});
});
} else if (track is RemoteVideoTrack) {
statsType = ParticipantStatsType.remoteVideoReceiver;
listener.on<VideoReceiverStatsEvent>((event) {
setState(() {
stats['video rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['video codec'] = '${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
stats['video size'] =
'${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
stats['video jitter'] = '${event.stats.jitter} s';
stats['video decoder'] = '${event.stats.decoderImplementation}';
stats['video packets lost'] = '${event.stats.packetsLost}';
stats['video packets received'] = '${event.stats.packetsReceived}';
stats['video frames received'] = '${event.stats.framesReceived}';
stats['video frames decoded'] = '${event.stats.framesDecoded}';
stats['video frames dropped'] = '${event.stats.framesDropped}';
});
});
} else if (track is LocalAudioTrack) {
statsType = ParticipantStatsType.localAudioSender;
listener.on<AudioSenderStatsEvent>((event) {
setState(() {
stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs';
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
});
});
} else if (track is RemoteAudioTrack) {
statsType = ParticipantStatsType.remoteAudioReceiver;
listener.on<AudioReceiverStatsEvent>((event) {
setState(() {
stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs';
stats['audio codec'] =
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
stats['audio jitter'] = '${event.stats.jitter} s';
stats['audio concealed samples'] = '${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
stats['audio packets lost'] = '${event.stats.packetsLost}';
stats['audio packets received'] = '${event.stats.packetsReceived}';
});
});
}
}
onParticipantChanged() {
for (var element in listeners) {
element.dispose();
}
listeners.clear();
for (var track in [...widget.participant.videoTrackPublications, ...widget.participant.audioTrackPublications]) {
if (track.track != null) {
_setUpListener(track.track!);
}
}
}
@override
void initState() {
super.initState();
widget.participant.addListener(onParticipantChanged);
onParticipantChanged();
}
@override
void deactivate() {
for (var element in listeners) {
element.dispose();
}
widget.participant.removeListener(onParticipantChanged);
super.deactivate();
}
num sendBitrate = 0;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withOpacity(0.3),
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
child: Column(
children: stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
),
);
}
}

View File

@ -19,7 +19,7 @@ class NotificationNotifier extends StatefulWidget {
}
class _NotificationNotifierState extends State<NotificationNotifier> {
void connect() {
void connect() async {
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
@ -30,19 +30,21 @@ class _NotificationNotifierState extends State<NotificationNotifier> {
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
nty.fetch(auth);
nty.connect(auth).then((snapshot) {
snapshot!.stream.listen(
(event) {
final result = model.Notification.fromJson(jsonDecode(event));
nty.onRemoteMessage(result);
},
onError: (_, __) => connect(),
onDone: () => connect(),
);
if (await auth.isAuthorized()) {
nty.fetch(auth);
nty.connect(auth).then((snapshot) {
snapshot!.stream.listen(
(event) {
final result = model.Notification.fromJson(jsonDecode(event));
nty.onRemoteMessage(result);
},
onError: (_, __) => connect(),
onDone: () => connect(),
);
});
}
notify.close();
});
notify.close();
}
@override