Livestreaming
In this guide, we'll learn how to broadcast and watch a livestream using the Stream Video Flutter SDK. We will show you how to implement common livestream features, such as displaying the number of watchers, allowing users to wait before the livestream starts, handling different states, and much more.
For optimal mobile broadcasting performance, see our Mobile Livestreaming Broadcasting Guide which covers crucial mobile-specific considerations including thermal management, battery optimization, network connectivity, and app resource management during streaming.
This guide covers both sides of a livestream:
- The host creates the call, publishes camera and microphone, sees their own preview, goes live and ends the stream. See Hosting a livestream below, and the Hosting a Livestream cookbook for a complete host screen.
- Viewers discover live streams, join without publishing and render the host's video. See Watching a livestream below, and the Watching a Livestream cookbook for the built-in
LivestreamPlayer.
A complete working project is also available on GitHub.
Hosting a livestream
The host is the user who publishes audio and video. Hosting takes five steps: request device permissions, create the call with the host role, join with camera and microphone enabled, go live, and end the stream when done.
Request camera and microphone permissions
Declaring the permissions in AndroidManifest.xml and Info.plist (see Installation) is not enough on its own. On Android 6+ and on iOS, camera and microphone are runtime permissions, so ask for them before the host joins. Without them the host joins the call but cannot publish any tracks.
One way to do this is the permission_handler package:
import 'package:permission_handler/permission_handler.dart';
Future<bool> requestHostPermissions() async {
final statuses = await [
Permission.camera,
Permission.microphone,
].request();
return statuses.values.every((status) => status.isGranted);
}On iOS, permission_handler also needs the PERMISSION_CAMERA=1 and PERMISSION_MICROPHONE=1 preprocessor macros in your Podfile; see the package's setup instructions. Viewers who only watch do not need these permissions.
Create the call and join as host
Create the call with the current user as a member with the host role, then join it:
final call = StreamVideo.instance.makeCall(
callType: StreamCallType.liveStream(),
id: 'your_call_id',
);
final createResult = await call.getOrCreate(
members: [
MemberRequest(
userId: StreamVideo.instance.currentUser.id,
role: 'host',
),
],
);
if (createResult.isFailure) {
// For example: the user is missing the CreateCall permission.
debugPrint('Could not create: ${createResult.getErrorOrNull()?.message}');
return;
}
final joinResult = await call.join();
if (joinResult.isFailure) {
debugPrint('Could not join: ${joinResult.getErrorOrNull()?.message}');
return;
}A few things to note:
- Camera and microphone follow the call type settings.
join()turns the camera and microphone on or off according to thelivestreamcall type's settings (camera default on, mic default on) in the Stream Dashboard. Configure them there for your hosts. - Check every
Result.getOrCreate(),join(),goLive(),stopLive()andend()return aResultinstead of throwing. UseisSuccess/isFailureandgetErrorOrNull()to surface failures, for example a missing permission for the user's role. - Large audiences. If the stream is expected to reach a large audience, also pass
hintHighScaleLivestreamPublisher: trueto the host'sjoin(). It marks the participant as publishing to a large audience.
final joinResult = await call.join(
hintHighScaleLivestreamPublisher: true,
);If your app lets the host turn the camera or microphone on or off before joining, for example with toggles on a pre-join screen, pass those choices as connectOptions. They override the call type defaults for this join:
final joinResult = await call.join(
connectOptions: CallConnectOptions(
camera: TrackOption.fromSetting(enabled: isCameraOn),
microphone: TrackOption.fromSetting(enabled: isMicrophoneOn),
),
);TrackOption.fromSetting() is a factory constructor, so the CallConnectOptions cannot be const.
After joining, the call is still in backstage: the host is connected and publishing, but viewers without the join-backstage capability cannot join yet.
Show the host their own preview
The host's own video is the local participant, available as state.localParticipant. Render it with StreamCallParticipant, the same widget viewers use for the host:
CallParticipantState? _selectLocalParticipant(CallState state) =>
state.localParticipant;
PartialCallStateBuilder(
call: call,
selector: _selectLocalParticipant,
builder: (context, localParticipant) {
if (localParticipant == null) {
return const Center(child: CircularProgressIndicator());
}
return StreamCallParticipant(
call: call,
participant: localParticipant,
showConnectionQualityIndicator: false,
showParticipantLabel: false,
showSpeakerBorder: false,
);
},
);Pass a static or top-level function as the selector, as above, not an inline closure like (state) => state.localParticipant. PartialCallStateBuilder subscribes again whenever the selector changes, and an inline closure is a new function on every rebuild of the parent widget. The examples in this guide all follow this pattern.
Do not look the host up by role among callParticipants on the host's own device: that pattern is for viewers, who need to find a remote participant.
Go live
Go live from a control that only the host sees. Viewers that are allowed into backstage (see Backstage mode) must not get a "Go Live" button:
Future<void> goLive(Call call) async {
final result = await call.goLive();
if (result.isFailure) {
debugPrint('Could not go live: ${result.getErrorOrNull()?.message}');
}
}goLive() takes optional flags to start HLS, recording or transcription at the same time; see Going live and stopping.
To only show host controls to users that may use them, check the call's own capabilities instead of hardcoding a role:
bool _selectCanEndCall(CallState state) =>
state.ownCapabilities.contains(CallPermission.endCall);
PartialCallStateBuilder(
call: call,
selector: _selectCanEndCall,
builder: (context, canEndCall) {
if (!canEndCall) return const SizedBox.shrink();
return HostControls(call: call);
},
);End the livestream
When the host is done, end the call for everyone and then leave it:
Future<void> endLivestream(Call call) async {
final result = await call.end();
if (result.isFailure) {
debugPrint('Could not end: ${result.getErrorOrNull()?.message}');
}
await call.leave();
}end()only works while the call's status is active, for example after a successfuljoin(). In any other state it returns a failure (invalid status: ...) without ending the call on the server, which is why the result must be checked.leave()afterwards is safe: ifend()already disconnected, it only finishes the local cleanup.- As a safety net, also call
leave()in your host screen'sdispose(), so navigating away (system back, an app-level route change) never leaves the host publishing in the background:
@override
void dispose() {
call.leave();
super.dispose();
}If the host should be able to pause and come back, use stopLive() instead of end(): it returns the call to backstage without ending it.
The Hosting a Livestream cookbook puts all of these steps together in a complete host screen.
Watching a livestream
In this guide we will see how to watch a WebRTC livestream. We also support HLS and RTMP-out.
Let's do a quick overview of these three technologies:
- WebRTC is ideal for real-time, low-latency streaming such as video calls or live auctions.
- HLS (HTTP Live Streaming) is great for large-scale distribution, offering broad compatibility and adaptive bitrate streaming. However, it typically has higher latency (5–30 seconds), making it less suitable for interactive use cases.
- RTMP (Real-Time Messaging Protocol) was once the standard for low-latency streaming to platforms like YouTube or Twitch. While it's being phased out in favor of newer protocols, it's still commonly used for ingesting streams due to its reliability and low latency (~2–5 seconds).
We will show you how to watch the WebRTC livestream and implement some common livestreaming features.
We offer a default component, LivestreamPlayer, that comes with a predefined UI, in case it fits your use-case.
Using the default livestream player is simple. You just need the Call object:
final call = StreamVideo.instance.makeCall(
callType: StreamCallType.liveStream(),
id: 'your_call_id',
);
final result = await call.getOrCreate();
if (result.isFailure) {
debugPrint('Could not load: ${result.getErrorOrNull()?.message}');
return;
}
LivestreamPlayer(call: call);LivestreamPlayer joins the call for you, with camera and microphone disabled. You can find more details, including what happens when the host ends the stream, on the Watching a Livestream page.
Finding live streams
Viewers usually pick a stream from a list. Use queryCalls with a filter for livestream calls that are live (not in backstage) and not ended:
final result = await StreamVideo.instance.queryCalls(
filterConditions: {
'type': 'livestream',
'backstage': false,
'ended_at': {r'$exists': false},
},
sorts: [SortParamRequest(field: 'created_at', direction: -1)],
);
final liveCalls = result.getDataOrNull()?.calls ?? [];
for (final queriedCall in liveCalls) {
final callId = queriedCall.call.cid.id;
// Show callId in your list, then open it with
// StreamVideo.instance.makeCall(callType: StreamCallType.liveStream(), id: callId)
}This works for regular viewers as well as server-side. See Querying Calls for more filters, sorting and pagination.
The rest of the guide focuses on building your own livestream player UI.
Livestream states
The viewer's screen depends on the state of the call. The host's screen is built differently: see Hosting a livestream above. The widget below is the viewer screen:
class LivestreamScreen extends StatelessWidget {
const LivestreamScreen({super.key, required this.call});
final Call call;
static ({bool isBackstage, DateTime? endedAt}) _selectState(
CallState state,
) =>
(isBackstage: state.isBackstage, endedAt: state.endedAt);
@override
Widget build(BuildContext context) {
return PartialCallStateBuilder(
call: call,
selector: _selectState,
builder: (context, callState) {
if (callState.endedAt != null) {
return CallEndedContent(call: call);
}
if (callState.isBackstage) {
return BackstageContent(call: call);
}
return CallLiveContent(call: call);
},
);
}
}This won't compile yet since we still need to define the inner widgets, but first let's describe the different states:
- When
state.isBackstageistrue, the call is not started yet (it's in backstage). By default, only users with thejoin-backstagecapability (hosts) can join a call in this state. You can also specify thejoinAheadTimeSecondsparameter to allow any type of user to join the livestream before it's started. - If
state.endedAtis notnull, it means that the livestream has already finished. - If it's not ended or in backstage, then the livestream is live. In this state, we will show the host's video and other relevant data.
Backstage mode
While in backstage, you can show a countdown, or the start date. By listening to the isBackstage property from the CallState, you can also automatically render the video track as soon as it's available.
Here's an example of a viewer's backstage view, showing the start date and the number of viewers that are waiting for the livestream to start. It has no "Go Live" button on purpose: any user who can join backstage would see it. Going live belongs in the host's own screen (see Go live).
class BackstageContent extends StatelessWidget {
const BackstageContent({super.key, required this.call});
final Call call;
static ({DateTime? startsAt, int waitingCount}) _selectBackstage(
CallState state,
) =>
(
startsAt: state.startsAt,
waitingCount: state.callParticipants
.where((p) => !p.roles.contains('host'))
.length,
);
@override
Widget build(BuildContext context) {
return PartialCallStateBuilder(
call: call,
selector: _selectBackstage,
builder: (context, data) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
data.startsAt != null
? 'Livestream starting at ${data.startsAt!.toLocal()}'
: 'Livestream starting soon',
style: Theme.of(context).textTheme.titleLarge,
),
if (data.waitingCount > 0) ...[
const SizedBox(height: 16),
Text('${data.waitingCount} viewers waiting'),
],
],
),
);
},
);
}
}The livestream leaves backstage when the host calls call.goLive(). The viewer's PartialCallStateBuilder picks up the change and switches to the live view.
To allow viewers to join the call before it starts, set the joinAheadTimeSeconds parameter when updating the call:
final result = await call.update(
startsAt: DateTime.now().toUtc().add(const Duration(minutes: 5)),
backstage: const StreamBackstageSettings(
enabled: true,
joinAheadTimeSeconds: 300,
),
);Viewers who join ahead are in the same call as the host, so they receive the host's audio and video before goLive() is called. If the host is already joined with camera and microphone on, early viewers see and hear the preparation. Keep joinAheadTimeSeconds at 0 unless you intend a pre-show, or have the host keep the camera and microphone off until going live.
Call Ended
When a livestream has ended, its endedAt property is updated with the date and time when the livestream finished. You can use this to show a message to the users, and additionally show them recordings of the call, if available.
This also happens for a viewer who is watching when the host calls end(): the call.ended event sets endedAt, and the viewer's status becomes CallStatusDisconnected with a DisconnectReasonEnded reason. The LivestreamScreen above switches to the ended view on its own.
class CallEndedContent extends StatefulWidget {
const CallEndedContent({super.key, required this.call});
final Call call;
@override
State<CallEndedContent> createState() => _CallEndedContentState();
}
class _CallEndedContentState extends State<CallEndedContent> {
late Future<Result<List<CallRecording>>> _recordingsFuture;
@override
void initState() {
super.initState();
_recordingsFuture = widget.call.listRecordings();
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Livestream has ended'),
const SizedBox(height: 24),
FutureBuilder(
future: _recordingsFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
final recordings = snapshot.data!.getDataOrNull();
if (recordings == null || recordings.isEmpty) {
return const Text('No recordings available');
}
return Column(
children: [
const Text('Watch recordings:'),
const SizedBox(height: 8),
...recordings.map(
(recording) => TextButton(
onPressed: () {
// Open recording URL
},
child: Text(recording.filename),
),
),
],
);
},
),
],
),
);
}
}Call Live
Let's provide an example implementation for the viewer's widget when the livestream is active. The viewer finds the host among the remote participants by role (on the host's own device, use state.localParticipant instead, see Show the host their own preview):
class CallLiveContent extends StatelessWidget {
const CallLiveContent({super.key, required this.call});
final Call call;
static ({CallParticipantState? host, int viewerCount}) _selectLive(
CallState state,
) {
final participants = state.callParticipants;
return (
host: participants.where((p) => p.roles.contains('host')).firstOrNull,
viewerCount: participants.where((p) => !p.roles.contains('host')).length,
);
}
@override
Widget build(BuildContext context) {
return PartialCallStateBuilder(
call: call,
selector: _selectLive,
builder: (context, data) {
final host = data.host;
return Stack(
children: [
if (host != null)
StreamCallParticipant(
call: call,
participant: host,
showConnectionQualityIndicator: false,
showParticipantLabel: false,
showSpeakerBorder: false,
)
else
const Center(
child: Text("The host's video is not available"),
),
Positioned(
top: 16,
left: 16,
child: StreamBuilder<Duration>(
stream: call.callDurationStream,
builder: (context, snapshot) {
final duration = snapshot.data ?? Duration.zero;
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return Text(
'${minutes}m ${seconds}s',
style: const TextStyle(color: Colors.white),
);
},
),
),
Positioned(
top: 16,
right: 16,
child: Text(
'Viewers: ${data.viewerCount}',
style: const TextStyle(color: Colors.white),
),
),
],
);
},
);
}
}Let's break down the implementation:
Rendering the livestream track
Finding and rendering the participant whose video track is shown depends on your use-case: whether it supports only one streamer, whether you want to limit this functionality by role, or any other special requirements. In the example above, we're trying to find a user with the host role.
This example uses the StreamCallParticipant widget for rendering. You can also use the lower-level StreamVideoRenderer for full control over rendering, as described in our Video Renderer docs.
Livestream information
You can show various information about the livestream, such as the duration and the participant count:
state.callParticipantsincludes the host, so count only the non-host participants for a "viewers" label, as in the example above.state.participantCountandstate.anonymousParticipantCounthold the session totals, which also cover anonymous viewers.- The call duration is available via
call.callDurationStream, which provides a stream of the elapsed time since the call went live.
You can also watch queried calls, as explained here. This allows you to present participant count (and other call data), even without joining a call.
Error states
Livestreaming depends on many factors, such as network conditions on both the publishing and viewer side.
Proper error handling is needed to be transparent about the potential issues the user might be facing.
When the network drops, the SDK tries to reconnect the user to the call. However, if it fails to do that, the status in the CallState becomes CallStatusDisconnected. This gives you the chance to show an alert to the user and provide some custom handling (e.g. a message to check the network connection and try again).
Here's an example on how to do that:
call.partialState((state) => state.status).listen((status) {
if (status is CallStatusReconnecting) {
// The default SDK components will aready show some indicators
// but you can also react to this status on your end
} else if (status is CallStatusReconnectionFailed) {
// Show "Cannot join livestream. Try again later" message
} else if (status is CallStatusDisconnected) {
// Show "You are disconnected" message
}
});Additionally, check the Result of every call action (creating a call, joining a call, going live, ending) to visually notify the user if there's a failure. These methods return a failure instead of throwing, so an unchecked result fails silently:
final createResult = await call.getOrCreate();
if (createResult.isFailure) {
// Show "Livestream not found" or a permission error
return;
}
final joinResult = await call.join();
if (joinResult.isFailure) {
// Show "Cannot join livestream. Try again later"
return;
}Handling the camera and microphone
To offer the host the possibility to enable or disable the camera and microphone, use the call.setCameraEnabled() and call.setMicrophoneEnabled() methods:
await call.setCameraEnabled(enabled: true);
await call.setMicrophoneEnabled(enabled: true);You can observe the current state of devices to update your UI:
({bool isCameraEnabled, bool isMicrophoneEnabled}) _selectDevices(
CallState state,
) =>
(
isCameraEnabled: state.localParticipant?.isVideoEnabled ?? false,
isMicrophoneEnabled: state.localParticipant?.isAudioEnabled ?? false,
);
PartialCallStateBuilder(
call: call,
selector: _selectDevices,
builder: (context, data) {
return Row(
children: [
IconButton(
icon: Icon(
data.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
),
onPressed: () =>
call.setCameraEnabled(enabled: !data.isCameraEnabled),
),
IconButton(
icon: Icon(
data.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
),
onPressed: () => call.setMicrophoneEnabled(
enabled: !data.isMicrophoneEnabled,
),
),
],
);
},
);For more details on camera and microphone configuration, see the Camera and Microphone docs.
Broadcasting (HLS)
The Stream Video Flutter SDK has support for HLS broadcasting. This allows you to broadcast a livestream over HLS, which can then be watched using any HLS-compatible player.
Start and stop HLS broadcasting
// Start HLS broadcasting
final result = await call.startHLS();
if (result.isSuccess) {
final hlsUrl = result.getDataOrNull();
// hlsUrl contains the HLS playlist URL
}
// Stop HLS broadcasting
await call.stopHLS();After a few seconds of setup, broadcasting will start and the state of the call will be updated: the isBroadcasting flag will become true.
bool _selectIsBroadcasting(CallState state) => state.isBroadcasting;
PartialCallStateBuilder(
call: call,
selector: _selectIsBroadcasting,
builder: (context, isBroadcasting) {
return Text(isBroadcasting ? 'Broadcasting' : 'Not broadcasting');
},
);Listening to broadcasting events
You can listen to broadcasting-related events via the callEvents stream on the Call object:
call.callEvents.on<StreamCallBroadcastingStartedEvent>((event) {
// Broadcasting has started
});
call.callEvents.on<StreamCallBroadcastingStoppedEvent>((event) {
// Broadcasting has stopped
});
call.callEvents.on<StreamCallBroadcastingFailedEvent>((event) {
// Broadcasting failed
});Retrieving the broadcast URL
The HLS playlist URL can be retrieved from the call state after broadcasting has started:
final hlsUrl = call.state.value.egress.hlsPlaylistUrl;This URL can be used by others to watch the broadcast in any HLS-compatible player.
Displaying HLS
To display an HLS stream in your Flutter app, you can use a video player package that supports HLS playback, such as video_player or media_kit:
import 'package:video_player/video_player.dart';
final controller = VideoPlayerController.networkUrl(
Uri.parse(hlsUrl),
);
await controller.initialize();
controller.play();Starting HLS when going live
You can also start HLS broadcasting automatically when the call goes live by passing startHls: true to the goLive method:
await call.goLive(startHls: true);This is a convenient way to ensure HLS broadcasting starts alongside the WebRTC stream.
RTMP-In
You can use RTMP streams as input for a call. This is useful for integrating professional broadcasting tools like OBS.
The RTMP address and stream key are available from the call state:
final rtmpAddress = call.state.value.rtmpIngress;The streaming key for OBS and other tools uses the format: apiKey/userToken.
To configure OBS with Stream:
- Set the RTMP URL to the
rtmpAddressfrom the call state - Set the streaming key to
your_api_key/user_token - Start streaming from OBS
A user with the name and token provided to OBS will appear in the call. Consider creating a dedicated user for OBS streaming.
We plan to add support for other livestreaming protocols in the future. If something is missing, be sure to let us know.
Going live and stopping
To start and stop the livestream programmatically:
// Go live (exits backstage mode)
final goLiveResult = await call.goLive();
// Or go live and start HLS, recording and transcription at the same time
final goLiveWithOptionsResult = await call.goLive(
startHls: true,
startRecording: true,
startTranscription: true,
);
// Stop the livestream (returns to backstage mode)
final stopResult = await call.stopLive();
// End the call entirely, then leave it
final endResult = await call.end();
await call.leave();Check each Result as shown in Error states. The difference between stopLive() and end():
stopLive()returns the call to backstage mode. The host can go live again later.end()ends the call permanently for everyone. No one can rejoin. It only succeeds while the call's status is active (after a successfuljoin()); otherwise it returns a failure and the call is not ended. Follow it withleave(), and also callleave()in the host screen'sdispose(), see End the livestream.
User roles and permissions
For livestreaming, it's important to understand how roles and permissions work:
- Each user has a role that is scoped per call.
- The default role is
user. You can assign a different role when creating a call or adding members viagetOrCreate(). - In the Stream Dashboard under Roles & Permissions, permissions are configured per call type and per role. Review the settings for the
livestreamcall type to ensure they align with your use case. - By default, the
userrole may not have theCreateCallpermission, so users who should create/start livestreams need thehostrole (or the permission needs to be granted touser). - The same applies to the Join Backstage permission: by default only hosts can join when the call is not live yet. For regular users,
join()will fail in that state.
final call = StreamVideo.instance.makeCall(
callType: StreamCallType.liveStream(),
id: 'my_livestream',
);
await call.getOrCreate(
members: [
MemberRequest(
userId: StreamVideo.instance.currentUser.id,
role: 'host',
),
],
);