Building video calls in Flutter becomes more reliable when the app can understand what happens during a live session. In-call statistics reveal how well the connection performs, how stable the stream is, and where issues may appear before users even notice them.

Adding these metrics creates a clearer picture of the call"s real-time behavior, making it easier to debug and optimize quality. This foundation helps developers create video experiences that feel uninterrupted, respond faster, and adapt to changing network conditions.

Prerequisites

Teams set up Flutter projects with proper permissions to access cameras and microphones without crashes. Video SDKs and app credentials prepare the foundation so calls connect instantly when users tap join.

  • Flutter project created with camera and microphone permissions added to Android and iOS manifests.
  • Video SDK package like Agora RTC Engine or VideoSDK included in pubspec.yaml and installed.
  • UI widgets are prepared, such as Text displays and overlays for showing live stats data.
  • App ID or token from the video SDK provider is ready for authentication.

Setting Up the Call Engine

The call engine starts with SDK initialization to handle video and audio streams reliably. Event listeners connect early to capture network stats from the first frame, showing quality issues before they affect viewers.

Step 1: Add the video SDK dependency to pubspec.yaml and run flutter pub get.

code
dependencies:
agora_rtc_engine: ^6.3.2

Step 2: Import the SDK package in the main Dart file.

code
import 'package:agora_rtc_engine/agora_rtc_engine.dart';

Step 3: Initialize the RtcEngine or call the engine instance with your App ID.

code
final engine = createAgoraRtcEngine();
await engine.initialize(const RtcEngineContext(
appId: 'your-app-id-here',
));

Step 4: Enable the video module and set up local video view for camera preview.

code
await engine.enableVideo();
await engine.startPreview();

Step 5: Attach event listeners for stats callbacks like onNetworkQuality or onRtcStats.

code
engine.registerEventHandler(RtcEngineEventHandler(
onNetworkQuality: (quality) => print('Local quality: $quality'),
));
Banner for Profiling and Debugging

Joining the Video Call

Users enter call rooms by channel name to sync local and remote video feeds smoothly. This step establishes connections so everyone sees faces clearly without black screens or delays.

Step 1: Get the call ID or channel name from user input.

code
String channelName = 'meeting-room-123';

Step 2: Request camera and microphone permissions if not granted.

code
await Permission.camera.request();
await Permission.microphone.request();

Step 3: Join the channel using the call ID and generate or use a temporary token.

code
await engine.joinChannel(
token: 'temporary-token',
channelId: channelName,
uid: 0,
options: ChannelMediaOptions(),
);

Step 4: Set up remote video view widgets for other participants.

code
RtcLocalView(surfaceView: true);
RtcRemoteView(SurfaceView(uid: remoteUid));

Step 5: Start local video preview and enable remote video subscription.

code
await engine.setLocalRenderMode(VideoRenderModeHidden);

Displaying Local Video Stats

Local stats overlay shows your own sent bitrate and packet loss right on the camera preview. Numbers help you spot when your WiFi drops frames, letting you fix issues before others complain.

Step 1: Create a stats overlay widget positioned over the local video view.

code
Positioned(
top: 20,
right: 20,
child: Container(
padding: EdgeInsets.all(8),
color: Colors.black54,
child: Column(children: [statsTextWidgets]),
),
)

Step 2: Register a listener for local network quality events from the SDK.

code
engine.registerEventHandler(
onLocalVideoStats: (stats) {
setState(() {
localBitrate = stats.sendBitrate;
localPacketLoss = stats.txPacketLossRate;
});
},
);

Step 3: Update text fields with sent bitrate, packet loss percentage, and CPU usage.

code
Text('Bitrate: ${localBitrate / 1000} kbps'),
Text('Loss: ${localPacketLoss}%'),

Step 4: Format numbers to show changes like "Bitrate: 1.2 Mbps → ".

code
Text('Bitrate: ${formatBitrate(localBitrate)} ${trendIcon}'),

Step 5: Refresh the display every second using a timer.

code
Timer.periodic(Duration(seconds: 1), (timer) => setState(() {}));

Tracking Remote Connection Stats

Remote stats monitor each participant's incoming video quality with delay and jitter readings. Color warnings highlight struggling connections so the group knows who needs to switch networks.

Step 1: Listen for remote user events like userJoined or userNetworkQuality.

code
onUserJoined: (uid, elapsed) {
remoteUsers[uid] = RemoteUserStats();
},
onRemoteVideoStats: (stats) {
updateRemoteStats(stats.uid, stats);
},

Step 2: Assign stats data to each remote video tile based on user ID.

code
Map<int, RemoteUserStats> remoteUsers = {};

Step 3: Show incoming bitrate, delay, and jitter values per participant.

code
Text('Remote ${uid}: ${stats.rxBitrate/1000} kbps'),
Text('Delay: ${stats.e2eDelay}ms'),

Step 4: Color-code stats panels red if packet loss exceeds 5%.

code
Color statsColor = stats.rxPacketLossRate > 5 ? Colors.red : Colors.green;

Step 5: Update remote stats when new users join or leave the call.

code
onUserOffline: (uid, reason) => remoteUsers.remove(uid),

Updating Stats in Real Time

Real-time updates push fresh network data to the screen every few seconds without freezing video. Smoothing prevents numbers from jumping wildly, keeping stats readable during fast network changes.

Step 1: Set up periodic callbacks from the SDK for network stats every 2 seconds.

code
await engine.enableNetworkQualityStats(true);

Step 2: Parse incoming data for jitter, resolution, and frame rate changes.

code
void updateStats(RtcStats stats) {
jitter = stats.lastMileDelay;
fps = stats.videoSendFps;
}

Step 3: Push updates to the StatefulWidget setState for immediate UI refresh.

code
setState(() {
currentStats = parsedStats;
});

Step 4: Throttle updates to avoid overwhelming the render loop.

code
if (DateTime.now().difference(lastUpdate).inMilliseconds > 500) {
setState(() {});
}

Step 5: Add smoothing to the stats display to prevent flickering numbers.

code
double smoothedBitrate = (previousBitrate * 0.7) + (newBitrate * 0.3);

Ending Calls Cleanly

Clean shutdown releases camera access and clears memory to prevent battery drain or crashes. Final stats logs help teams review call performance after sessions end.

Step 1: Call the leaveChannel method on the engine instance.

code
await engine.leaveChannel();

Step 2: Stop local video capture and disable all audio tracks.

code
await engine.stopPreview();
await engine.muteLocalAudioStream(true);

Step 3: Dispose of stats listeners and timers to free resources.

code
_timer?.cancel();
engine.removeEventHandler(eventHandler);

Step 4: Save final stats like total duration and average bitrate to logs.

code
print('Call ended. Avg bitrate: ${totalBitrate / duration} kbps');

Step 5: Destroy the engine instance and navigate back to the home screen.

code
await engine.release();
Navigator.pop(context);