Skip to content

Commit

Permalink
feat: Stats monitor for Track. (#290)
Browse files Browse the repository at this point in the history
* feat: Stats monitor for Track.

* fix analyze.

* fix.

* import sorter.

* add monitor layer for example.

* update.

* update.

* fix import sorter.

* update.

* import sorter.

* fix typo for filename.

* revert changes for VideoPublishOptions.videoCodec.

* chore: improve code.

* update.
  • Loading branch information
cloudwebrtc authored Jun 1, 2023
1 parent f21236b commit 272484c
Show file tree
Hide file tree
Showing 11 changed files with 803 additions and 36 deletions.
55 changes: 32 additions & 23 deletions example/lib/pages/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -190,31 +190,40 @@ class _RoomPageState extends State<RoomPage> {

@override
Widget build(BuildContext context) => Scaffold(
body: Column(
body: Stack(
children: [
Expanded(
child: participantTracks.isNotEmpty
? ParticipantWidget.widgetFor(participantTracks.first)
: Container()),
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, participantTracks.length - 1),
itemBuilder: (BuildContext context, int index) => SizedBox(
width: 100,
height: 100,
child:
ParticipantWidget.widgetFor(participantTracks[index + 1]),
),
),
Column(
children: [
Expanded(
child: participantTracks.isNotEmpty
? ParticipantWidget.widgetFor(participantTracks.first,
showStatsLayer: true)
: Container()),
if (widget.room.localParticipant != null)
SafeArea(
top: false,
child: ControlsWidget(
widget.room, widget.room.localParticipant!),
)
],
),
if (widget.room.localParticipant != null)
SafeArea(
top: false,
child:
ControlsWidget(widget.room, widget.room.localParticipant!),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, participantTracks.length - 1),
itemBuilder: (BuildContext context, int index) => SizedBox(
width: 180,
height: 120,
child: ParticipantWidget.widgetFor(
participantTracks[index + 1]),
),
),
)),
],
),
);
Expand Down
29 changes: 23 additions & 6 deletions example/lib/widgets/participant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ 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) {
static ParticipantWidget widgetFor(ParticipantTrack participantTrack,
{bool showStatsLayer = false}) {
if (participantTrack.participant is LocalParticipant) {
return LocalParticipantWidget(
participantTrack.participant as LocalParticipant,
participantTrack.videoTrack,
participantTrack.isScreenShare);
participantTrack.isScreenShare,
showStatsLayer);
} else if (participantTrack.participant is RemoteParticipant) {
return RemoteParticipantWidget(
participantTrack.participant as RemoteParticipant,
participantTrack.videoTrack,
participantTrack.isScreenShare);
participantTrack.isScreenShare,
showStatsLayer);
}
throw UnimplementedError('Unknown participant type');
}
Expand All @@ -29,6 +33,7 @@ abstract class ParticipantWidget extends StatefulWidget {
abstract final Participant participant;
abstract final VideoTrack? videoTrack;
abstract final bool isScreenShare;
abstract final bool showStatsLayer;
final VideoQuality quality;

const ParticipantWidget({
Expand All @@ -44,11 +49,14 @@ class LocalParticipantWidget extends ParticipantWidget {
final VideoTrack? videoTrack;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;

const LocalParticipantWidget(
this.participant,
this.videoTrack,
this.isScreenShare, {
this.isScreenShare,
this.showStatsLayer, {
Key? key,
}) : super(key: key);

Expand All @@ -63,11 +71,14 @@ class RemoteParticipantWidget extends ParticipantWidget {
final VideoTrack? videoTrack;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;

const RemoteParticipantWidget(
this.participant,
this.videoTrack,
this.isScreenShare, {
this.isScreenShare,
this.showStatsLayer, {
Key? key,
}) : super(key: key);

Expand Down Expand Up @@ -136,7 +147,13 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget>
)
: const NoVideoWidget(),
),

if (widget.showStatsLayer)
Positioned(
top: 30,
right: 30,
child: ParticipantStatsWidget(
participant: widget.participant,
)),
// Bottom bar
Align(
alignment: Alignment.bottomCenter,
Expand Down
139 changes: 139 additions & 0 deletions example/lib/widgets/participant_stats.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';

enum StatsType {
kUnknown,
kLocalAudioSender,
kLocalVideoSender,
kRemoteAudioReceiver,
kRemoteVideoReceiver,
}

class ParticipantStatsWidget extends StatefulWidget {
const ParticipantStatsWidget({Key? key, required this.participant})
: super(key: key);
final Participant participant;
@override
State<StatefulWidget> createState() => _ParticipantStatsWidgetState();
}

class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
List<EventsListener<TrackEvent>> listeners = [];
StatsType statsType = StatsType.kUnknown;
Map<String, String> stats = {};

void _setUpListener(Track track) {
var listener = track.createListener();
listeners.add(listener);
if (track is LocalVideoTrack) {
statsType = StatsType.kLocalVideoSender;
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 = StatsType.kRemoteVideoReceiver;
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 = StatsType.kLocalAudioSender;
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 = StatsType.kRemoteAudioReceiver;
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.videoTracks,
...widget.participant.audioTracks
]) {
if (track.track != null) {
_setUpListener(track.track!);
}
}
}

@override
void initState() {
super.initState();
widget.participant.addListener(_onParticipantChanged);
// trigger initial change
_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()),
);
}
}
55 changes: 55 additions & 0 deletions lib/src/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'participant/remote.dart';
import 'publication/local.dart';
import 'publication/remote.dart';
import 'publication/track_publication.dart';
import 'track/stats.dart';
import 'track/track.dart';
import 'types/other.dart';
import 'types/participant_permissions.dart';
Expand Down Expand Up @@ -435,3 +436,57 @@ class AudioPlaybackStatusChanged with RoomEvent {
String toString() => '${runtimeType}'
'Audio Playback Status Changed, isPlaying: ${isPlaying})';
}

class AudioSenderStatsEvent with TrackEvent {
final AudioSenderStats stats;
final num currentBitrate;
const AudioSenderStatsEvent({
required this.stats,
required this.currentBitrate,
});

@override
String toString() => '${runtimeType}'
'stats: ${stats})';
}

class VideoSenderStatsEvent with TrackEvent {
final Map<String, VideoSenderStats> stats;
final Map<String, num> bitrateForLayers;
final num currentBitrate;
const VideoSenderStatsEvent({
required this.stats,
required this.currentBitrate,
required this.bitrateForLayers,
});

@override
String toString() => '${runtimeType}'
'stats: ${stats})';
}

class AudioReceiverStatsEvent with TrackEvent {
final AudioReceiverStats stats;
final num currentBitrate;
const AudioReceiverStatsEvent({
required this.stats,
required this.currentBitrate,
});

@override
String toString() => '${runtimeType}'
'stats: ${stats})';
}

class VideoReceiverStatsEvent with TrackEvent {
final VideoReceiverStats stats;
final num currentBitrate;
const VideoReceiverStatsEvent({
required this.stats,
required this.currentBitrate,
});

@override
String toString() => '${runtimeType}'
'stats: ${stats})';
}
Loading

0 comments on commit 272484c

Please sign in to comment.