Skip to main content
Version: 5.x.x

Rendering Host and Audience Views - Flutter

In a live stream setup, only hosts (participants in SEND_AND_RECV mode) can broadcast their audio and video. Audience members (in RECV_ONLY mode) are passive viewers who do not share their audio/video.

To ensure optimal performance and a clean user experience, your app should:

  • Render audio and video elements only for hosts (i.e., participants in SEND_AND_RECV mode).
  • Display the total audience count to give context on viewership without rendering individual audience tiles.

Filtering and Rendering Hosts​

The steps involved in rendering the video of hosts are as follows.

  1. Filtering Hosts and Checking their Cam Status
  2. Rendering Video Streams of Hosts

1. Filtering Hosts and Checking their Mic and Cam Status​

In a live stream, only participants in SEND_AND_RECV mode (i.e., hosts) actively share their audio and video. To render their video streams, begin by accessing all participants using the Room class. Then, filter out only those in SEND_AND_RECV mode. room.participants holds the remote participants only, so add room.localParticipant once the room is joined.

note

Rebuild these lists in the participantModeChanged event, so a promoted or demoted participant moves between the audience and the stage on every client.

participant.mode is already updated when participantModeChanged fires, so the screen only rebuilds the grid then. It also rebuilds the grid when someone joins or leaves.

import 'package:flutter/material.dart';
import 'package:videosdk/videosdk.dart';

class LiveStreamScreen extends StatefulWidget {
...
}

class _LiveStreamScreenState extends State<LiveStreamScreen> {
late Room _room;
bool _joined = false;

@override
void initState() {
super.initState();
... // create the room
setUpRoomEventListeners();
... // join the room
}

void setUpRoomEventListeners() {
_room.on(Events.roomJoined, () => setState(() => _joined = true));

_room.on(Events.participantJoined, (Participant participant) {
setState(() {});
});

_room.on(Events.participantLeft, (Participant participant, LeaveReason reason) {
setState(() {});
});

_room.on(Events.participantModeChanged, (Map<String, dynamic> data) {
setState(() {}); // participant.mode is already updated
});
}

@override
Widget build(BuildContext context) {
return LiveStreamGrid(room: _room, joined: _joined);
}
}

For each of these participants, use the Participant class, which provides real-time information like displayName and streams. A host's mic is on while they have an audio stream, and their camera is on while they have a video stream. Display their name along with the current status of their microphone and Camera. If the camera is off, show a simple placeholder with their name. If it's on, render their video feed. This ensures only hosts are visible to the audience, keeping the experience clean and intentional.

ParticipantViewTile takes the Room as well as the Participant, because the room delivers the stream events that the tile uses in the next step.

import 'package:flutter/material.dart';
import 'package:videosdk/videosdk.dart';

class LiveStreamGrid extends StatelessWidget {
final Room room;
// localParticipant is set once the room is joined
final bool joined;
const LiveStreamGrid({Key? key, required this.room, required this.joined})
: super(key: key);

@override
Widget build(BuildContext context) {
// room.participants holds remote participants only, so add yourself.
final everyone = [
if (joined) room.localParticipant,
...room.participants.values,
];
// Filter only SEND_AND_RECV participants (hosts).
final hostParticipants = everyone
.where((participant) => participant.mode == Mode.SEND_AND_RECV)
.toList();
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: hostParticipants.length,
itemBuilder: (context, index) {
final participant = hostParticipants[index];
return ParticipantViewTile(
key: ValueKey(participant.id),
room: room,
participant: participant,
isLocal: participant.isLocal,
);
});
}
}

class ParticipantViewTile extends StatelessWidget {
final Room room;
final Participant participant;
final bool isLocal;

const ParticipantViewTile({
Key? key,
required this.room,
required this.participant,
this.isLocal = false,
}) : super(key: key);

@override
Widget build(BuildContext context) {
final micOn =
participant.streams.values.any((stream) => stream.kind == MediaType.AUDIO);
final camOn =
participant.streams.values.any((stream) => stream.kind == MediaType.VIDEO);

return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isLocal ? "You" : participant.displayName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Text("Cam: ${camOn ? 'On' : 'Off'}"),
Text("Mic: ${micOn ? 'On' : 'Off'}"),
],
),
);
}
}

2. Rendering Video Streams of Hosts​

Once you've filtered for participants in SEND_AND_RECV mode (i.e., hosts), you can use the Participant class to access their real-time data, including their video stream, their audio stream, and whether they are the local participant.

The streamEnabled and streamDisabled events are delivered on the Room, for every participant's streams. stream.participantId is the id of the participant the stream belongs to, so each tile keeps only its own host's streams. The room outlives the tile, so the tile removes its handlers with room.off() when it goes away.

class ParticipantViewTile extends StatefulWidget {
final Room room;
final Participant participant;
final bool isLocal;

const ParticipantViewTile({
Key? key,
required this.room,
required this.participant,
this.isLocal = false,
}) : super(key: key);

@override
State<ParticipantViewTile> createState() => _ParticipantViewTileState();
}

class _ParticipantViewTileState extends State<ParticipantViewTile> {
RTCStream? videoStream;
RTCStream? audioStream;

@override
void initState() {
super.initState();

// Initial stream assignment
for (final RTCStream stream in widget.participant.streams.values) {
if (stream.kind == MediaType.VIDEO) {
videoStream = stream;
} else if (stream.kind == MediaType.AUDIO) {
audioStream = stream;
}
}

_initStreamListeners();
}

// Stream events are delivered on the room.
// stream.participantId is the id of the participant the stream belongs to.
void _initStreamListeners() {
widget.room.on(Events.streamEnabled, _onStreamEnabled);
widget.room.on(Events.streamDisabled, _onStreamDisabled);
}

void _onStreamEnabled(RTCStream stream) {
if (stream.participantId != widget.participant.id) return;
setState(() {
if (stream.kind == MediaType.VIDEO) {
videoStream = stream;
} else if (stream.kind == MediaType.AUDIO) {
audioStream = stream;
}
});
}

void _onStreamDisabled(RTCStream stream) {
if (stream.participantId != widget.participant.id) return;
setState(() {
if (stream.kind == MediaType.VIDEO && videoStream?.id == stream.id) {
videoStream = null;
} else if (stream.kind == MediaType.AUDIO && audioStream?.id == stream.id) {
audioStream = null;
}
});
}

@override
void dispose() {
// The room outlives the tile, so remove the handlers from it.
widget.room.off(Events.streamEnabled, _onStreamEnabled);
widget.room.off(Events.streamDisabled, _onStreamDisabled);
super.dispose();
}

@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: [
if (videoStream != null)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: RTCVideoView(
videoStream?.renderer as RTCVideoRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
)
else
Center(
child: Container(
width: 60,
height: 60,
decoration: const BoxDecoration(
color: Colors.grey,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
widget.participant.displayName.characters.first.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontSize: 24,
),
),
),
),
Positioned(
bottom: 8,
left: 8,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isLocal ? "You" : widget.participant.displayName,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
Text(
"Cam: ${videoStream != null ? 'On' : 'Off'} | Mic: ${audioStream != null ? 'On' : 'Off'}",
style: const TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
),
],
),
);
}
}
caution

You don't need to render audio separately. VideoSDK plays the audio of the other participants automatically, so RTCVideoView is only needed for video.

API Reference​

The API references for all the methods and events utilized in this guide are provided below.

Got a Question? Ask us on discord