Platform docs
Auth, users, webhooks & more

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.

Info:

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:

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 the livestream call 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() and end() return a Result instead of throwing. Use isSuccess / isFailure and getErrorOrNull() 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: true to the host's join(). 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 successful join(). 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: if end() already disconnected, it only finishes the local cleanup.
  • As a safety net, also call leave() in your host screen's dispose(), 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.isBackstage is true, the call is not started yet (it's in backstage). By default, only users with the join-backstage capability (hosts) can join a call in this state. You can also specify the joinAheadTimeSeconds parameter to allow any type of user to join the livestream before it's started.
  • If state.endedAt is not null, 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,
  ),
);
Warning:

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.callParticipants includes the host, so count only the non-host participants for a "viewers" label, as in the example above. state.participantCount and state.anonymousParticipantCount hold 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:

  1. Set the RTMP URL to the rtmpAddress from the call state
  2. Set the streaming key to your_api_key/user_token
  3. Start streaming from OBS
Note:

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 successful join()); otherwise it returns a failure and the call is not ended. Follow it with leave(), and also call leave() in the host screen's dispose(), 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 via getOrCreate().
  • In the Stream Dashboard under Roles & Permissions, permissions are configured per call type and per role. Review the settings for the livestream call type to ensure they align with your use case.
  • By default, the user role may not have the CreateCall permission, so users who should create/start livestreams need the host role (or the permission needs to be granted to user).
  • 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',
    ),
  ],
);