In this tutorial, we will cover the steps to quickly build a low-latency live-streaming experience in Flutter using Stream's Video SDK. The livestream is broadcast using Stream's edge network of servers around the world. 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.
You can find a working project that uses the examples below here.
Livestream Quickstart
By the end you will have an app that:
- logs in as one of three users, so you can be the host on one device and a viewer on another
- creates a
livestreamcall and puts the host in backstage, where they can set up before an audience arrives - goes live on a button press, showing the live video feed and a running viewer count
- lets anyone watch from a browser tab, or from a second copy of the app with
LivestreamPlayer - optionally broadcasts to HLS, or ingests video from OBS over RTMP
You do not need a second device to follow along: Step 10 lets you watch from a browser tab.
Three ways video moves. This tutorial builds the WebRTC path first, then layers HLS (Step 13) and RTMP ingest (Step 14) on top:
- WebRTC provides ultra-low latency streaming (sub-second) - perfect for interactive experiences like live auctions or Q&As where real-time engagement is critical
- HLS (HTTP Live Streaming) enables reliable large-scale broadcasting with broad device compatibility and adaptive quality. While it has higher latency (5-30 seconds), it excels at reaching large audiences with stable playback
- RTMP (Real-Time Messaging Protocol) bridges professional broadcasting tools like OBS to your app with low latency (2-5 seconds). 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
Before we start, if you have any questions or feedback, please let us know via the feedback button.
This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.
Choose your path
This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step. Both paths end with a working livestream - Path A's is shaped by what you ask for, Path B's by the steps below - so pick the one you prefer instead of working through both.
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This step is required for Path A, not optional - the skills are what give Claude Code, Cursor, or Codex the Flutter integration patterns and current SDK APIs, so the agent builds against real docs instead of stale training data.
123456789# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Flutter pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-flutter --universal
Then ask your agent:
123456/stream-flutter Build a Flutter livestreaming app: a login screen for a few hardcoded users, a host screen that creates a call of type livestream, waits in backstage, and goes live on a button press with a viewer count, plus a second entry point that joins a call id and watches it with LivestreamPlayer. Provision credentials with the CLI: create or select my org and app, then mint a token for each user. If you can't mint them, ask me to provide fallback credentials.
Only the first line matters - /stream-flutter Build a Flutter livestreaming app is enough to get going. The rest just steers the result: ask for a room list instead of typing a call id, your own player chrome instead of LivestreamPlayer, or a co-host layout. Describe the UI you want rather than the UI the tutorial builds.
If the CLI can't mint credentials, provide the fallback ones from Step 3, Option 2 when the agent asks.
Where you come in. The agent writes the code, provisions the credentials, and edits AndroidManifest.xml and Info.plist for the camera and microphone permissions itself. It hands back to you twice during setup - browser sign-in, and picking which org and app to use during getstream init (new accounts get an organization created in that flow) - and once more at the end, when the app runs on a device and you grant camera and microphone access. Depending on what your agent is allowed to run, it may also ask you to run flutter create or flutter run yourself.
Already have your Flutter project? You can get the sign-in out of the way first: run getstream init from inside the project before you prompt the agent, and it picks up the initialized project from there.
Human checkpoint: build and run on a device, allow camera and microphone access, and press Go Live. Your own video should fill the screen with a viewer count above it. Then watch that same livestream from a browser tab and confirm the count goes up.
What the agent can't do. The optional RTMP route in Step 14 runs through OBS, a desktop application driven by hand. There is no CLI or API surface for it, so work through that step yourself if you want the RTMP path. Everything else on this page an agent can do end to end.
Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.
Path B - Build it manually
Choose this path if you'd rather write the code yourself, or want to understand what the SDK is doing. Follow the steps below.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Flutter Video SDK is athttps://getstream.io/video/docs/flutter/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Five pieces, one mental model:
StreamVideo- the low-level client. Holds the API key, the user, the token, and the websocket connection. Created once, when a user logs in, and reachable anywhere asStreamVideo.instance.Callof typelivestream- one livestream, created withStreamVideo.instance.makeCall(callType: StreamCallType.liveStream(), id:).call.getOrCreate()registers it on the backend andcall.join()is what actually sets up audio and video.- Backstage - the
livestreamcall type starts calls not live. The host sets up their camera first, andcall.goLive()is what lets viewers in.call.stopLive()closes it again. call.state- the observable state of the livestream.PartialCallStateBuilderrebuilds only when the value itsselectorreturns changes, which is what every widget below reads from. It carriesisBackstage,endedAt,startsAt,callParticipants,statusand more.LivestreamPlayervsStreamCallContainer-LivestreamPlayeris the drop-in viewer: give it a call and it joins and plays.StreamCallContaineris the host-side surface, which you customize through its*WidgetBuilderparameters.
Roles are scoped per call, and the livestream type only lets host create a call or join while backstage - which is why the code below adds the creator as a member with role: 'host'. All of it is editable per call type under Roles & Permissions in the Stream Dashboard, and Step 7 covers it in full.
Two ways to get video in: WebRTC straight from the device (Steps 6-9), or RTMP from software like OBS (Step 14). HLS (Step 13) is a third way to get it back out to viewers.
Step 1 - Create a New Flutter Project
To begin developing your livestreaming app, you need to create a new Flutter project. If you do not have Flutter or an IDE configured to work with it, we highly recommend following the Install and Set up an editor steps from the official documentation.
Please make sure you are using the latest version of Flutter from the stable channel:
12flutter channel stable flutter upgrade
Now create the project. For this tutorial we are calling it 'livestreaming_tutorial', matching the sample repository:
12flutter create livestreaming_tutorial --empty cd livestreaming_tutorial
Checkpoint: you have a livestreaming_tutorial directory and flutter run builds the Flutter starter app.
Step 2 - Install the SDK and Declare Permissions
Next, add Stream Video and intl (used to format the livestream start time) to your dependencies:
1flutter pub add stream_video_flutter intl
You should now have both in your pubspec.yaml. flutter pub add writes the current version for you; if you add them by hand instead, replace ^latest with a real constraint:
1234567dependencies: flutter: sdk: flutter # Replace ^latest with the current version from pub.dev, for example ^1.4.2 stream_video_flutter: ^latest intl: ^latest
Stream offers several packages for integrating video capabilities into your application:
- stream_video_flutter: Contains pre-built UI components for quick implementation
- stream_video: The core client SDK for direct API access.
stream_video_flutterre-exports it, so it arrives with the dependency above and you don't list it separately. - stream_video_push_notification: Provides push notification support and CallKit integration. We won't be using it in this tutorial.
Checkpoint: flutter pub get completes and import 'package:stream_video_flutter/stream_video_flutter.dart'; resolves.
Declare Permissions
Before proceeding, you need to add the permissions the host needs to publish camera and microphone.
For Android, update your AndroidManifest.xml file by adding these permissions:
12345678910111213141516<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET"/> <uses-feature android:name="android.hardware.camera"/> <uses-feature android:name="android.hardware.camera.autofocus"/> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> ... </manifest>
For iOS, open your Info.plist file and add:
123456789101112<key>NSCameraUsageDescription</key> <string>Camera access is needed to broadcast your livestream</string> <key>NSMicrophoneUsageDescription</key> <string>Microphone access is needed to broadcast your livestream</string> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>fetch</string> <string>processing</string> <string>remote-notification</string> <string>voip</string> </array>
stream_video_flutter requires iOS 14.0, and flutter create scaffolds the project at 13.0, so raise the deployment target or the build fails. Set Minimum Deployments to 14.0 in Xcode, or change all three occurrences of IPHONEOS_DEPLOYMENT_TARGET in ios/Runner.xcodeproj/project.pbxproj to 14.0.
Run the App
An Android emulator can watch a livestream, but build the host side to a physical device - emulator cameras are limited and the iOS Simulator has none. The first iOS device build also needs a signing team: open ios/Runner.xcworkspace in Xcode and, under Signing & Capabilities, select your Team and change the bundle identifier if the default is taken.
1flutter run
Human checkpoint: the app builds and launches on your device. Agents: run the build, then ask the human to confirm it launched.
Step 3 - Get Your Credentials
The code ahead needs an API key and three user tokens. Three, because the host and the viewer have to be different users - two clients signed in as the same user count as one participant - and a third gives you room to test. There are two ways to get them.
Option 1 - Your own Stream app, via the Stream CLI
The getstream CLI provisions all of it in one flow. Run these from the livestreaming_tutorial directory you created in Step 1 - the CLI stores project credentials there.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization. Already have an org or an app? It lets you pick them.
1getstream init
Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish signing in before continuing. It's required first - token and api fail with "project credentials missing", and open fails with "stream project is not initialized", until it runs.
3. Grab your API key. This opens the app you just selected in the Stream dashboard, where the API key is shown. Paste it into the code in Step 4.
1getstream open
4. Mint a token for each user (never expiring by default; add a TTL for production-like testing). The first user creates the livestream, which makes them the host:
123getstream token alice getstream token bob getstream token charlie
Checkpoint: you have an API key and three tokens, all belonging to the same app, plus the user id you minted each one for.
Pasting the API key straight into your source is fine for this tutorial - it's a publishable key, not a secret. The user tokens are the ones to be careful with: in production your backend mints them after sign-in and the app fetches them, rather than shipping hardcoded ones. The Client and Authentication guide covers the production shape.
Option 2 - Pre-filled tutorial credentials, no account
Want to skip account setup entirely? The snippet below hands you working credentials against Stream's shared tutorial environment, and the app_keys.dart block in Step 4 is marked with the lock icon - its API key and user1 fields are already filled in with these values, so you can copy it as-is.
Here are credentials to try out the app with:
| Property | Value |
|---|---|
| API Key | Waiting for an API key ... |
| Token | Token is generated ... |
| User ID | Loading ... |
| Call ID | Creating random call ID ... |
This gives you one user, not three, so user2 and user3 stay as placeholders. That's enough to follow the whole tutorial: be the host on your device and use the browser viewer in Step 10 as your second participant. Switch to Option 1 when you want all three.
Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.
Step 4 - Store Your Credentials
Create lib/app_keys.dart to hold the values from Step 3. Keeping them in one file means the rest of the app never hardcodes a token, and it's the only file you touch when you switch from the tutorial credentials to your own:
12345678910111213141516171819class AppKeys { // Your Stream API key from the Stream dashboard static const String streamApiKey = 'REPLACE_WITH_API_KEY'; // User 1 - the host, who creates the livestream static const String user1Id = 'REPLACE_WITH_USER_ID'; static const String user1Name = 'REPLACE_WITH_USER_NAME'; static const String user1Token = 'REPLACE_WITH_TOKEN'; // User 2 - a viewer static const String user2Id = 'REPLACE_WITH_USER_2_ID'; static const String user2Name = 'REPLACE_WITH_USER_2_NAME'; static const String user2Token = 'REPLACE_WITH_USER_2_TOKEN'; // User 3 - a second viewer static const String user3Id = 'REPLACE_WITH_USER_3_ID'; static const String user3Name = 'REPLACE_WITH_USER_3_NAME'; static const String user3Token = 'REPLACE_WITH_USER_3_TOKEN'; }
Checkpoint: flutter analyze reports no issues, and every placeholder above has been replaced with a real value from Step 3.
Step 5 - Add the Users and Build the Login Screen
To simplify testing, we'll create a TutorialUser class in tutorial_user.dart to wrap the credentials from Step 4 into User objects the SDK understands. Then we'll build a basic login page where you pick which one to log in as, plus a placeholder home screen for it to navigate to.
Copy the following content into each file, replacing whatever is there (the header of each block names the file):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import 'package:livestreaming_tutorial/app_keys.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class TutorialUser { const TutorialUser({ required this.user, required this.token, }); final User user; final String? token; factory TutorialUser.user1() => TutorialUser( user: User.regular( userId: AppKeys.user1Id, name: AppKeys.user1Name, image: 'https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg?auto=compress&cs=tinysrgb&w=600', ), token: AppKeys.user1Token, ); factory TutorialUser.user2() => TutorialUser( user: User.regular( userId: AppKeys.user2Id, name: AppKeys.user2Name, image: 'https://images.pexels.com/photos/415829/pexels-photo-415829.jpeg?auto=compress&cs=tinysrgb&w=600', ), token: AppKeys.user2Token, ); factory TutorialUser.user3() => TutorialUser( user: User.regular( userId: AppKeys.user3Id, name: AppKeys.user3Name, image: 'https://images.pexels.com/photos/1681010/pexels-photo-1681010.jpeg?auto=compress&cs=tinysrgb&w=600', ), token: AppKeys.user3Token, ); static List<TutorialUser> get users => [ TutorialUser.user1(), TutorialUser.user2(), TutorialUser.user3(), ]; }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778import 'package:flutter/material.dart'; import 'package:livestreaming_tutorial/app_keys.dart'; import 'package:livestreaming_tutorial/home_screen.dart'; import 'package:livestreaming_tutorial/tutorial_user.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); State<LoginScreen> createState() => _LoginScreenState(); } class _LoginScreenState extends State<LoginScreen> { TutorialUser? selectedUser; List<TutorialUser> users = TutorialUser.users; Widget build(BuildContext context) { return MaterialApp( home: Builder( builder: (context) { return Scaffold( body: Center( child: Column( spacing: 16, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Login as:', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 90), ...users.map((user) { return ElevatedButton( style: ElevatedButton.styleFrom( foregroundColor: selectedUser?.user.id == user.user.id ? Colors.green : null, ), onPressed: () { setState(() { selectedUser = user; }); }, child: Text(user.user.name ?? ''), ); }), const SizedBox(height: 90), TextButton( onPressed: selectedUser != null ? () async { await StreamVideo( AppKeys.streamApiKey, user: selectedUser!.user, userToken: selectedUser!.token, ).connect(); if (context.mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => const HomeScreen(), ), ); } } : null, child: const Text('Login'), ), ], ), ), ); }, ), ); } }
123456789101112131415import 'package:flutter/material.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { Widget build(BuildContext context) { return const Placeholder(); } }
After the user is selected, the app creates and connects a new StreamVideo client instance using the provided API key and user token, then navigates to the home screen for livestream creation.
This approach initializes the client for the selected user so they can start or join livestreams, keeping the logic simple and straightforward for the tutorial purposes.
Finally, point the app at the login screen. flutter create --empty scaffolds a placeholder main.dart, so replace it:
1234567891011121314151617181920212223242526import 'package:flutter/material.dart'; import 'package:livestreaming_tutorial/login_screen.dart'; Future<void> main() async { // Ensure Flutter is able to communicate with Plugins WidgetsFlutterBinding.ensureInitialized(); runApp(const MainApp()); } class MainApp extends StatefulWidget { const MainApp({ super.key, }); State<MainApp> createState() => _MainAppState(); } class _MainAppState extends State<MainApp> { Widget build(BuildContext context) { return const MaterialApp( home: LoginScreen(), ); } }
home_screen.dart is a placeholder for now, so the app builds and runs end to end - Step 6 replaces it with the real home screen.
Checkpoint: the app opens on the login screen with three buttons. A wrong API key or token fails here, loudly, before any call code runs. On the shared tutorial credentials only the first user is real; the other two buttons read REPLACE_WITH_USER_2_NAME and REPLACE_WITH_USER_3_NAME and fail if you tap them.
Step 6 - Build the Home Screen
To keep things simple, our sample application will only consist of two screens, a landing page to allow users the ability to create a livestream, and another page to view and control the livestream.
Now replace the placeholder home_screen.dart from Step 5. We'll implement a simple home screen that displays a button in the center - when pressed, this button will create and start a new livestream.
While the livestream is being created the button will be disabled. We also add a logout button to the app bar to allow the user to logout and navigate back to the login screen.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859import 'package:flutter/material.dart'; import 'package:livestreaming_tutorial/login_screen.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { String? createLoadingText; String? viewLoadingText; Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Livestreaming Tutorial'), centerTitle: true, automaticallyImplyLeading: false, actions: [ IconButton( icon: const Icon(Icons.logout), onPressed: () async { await StreamVideo.instance.disconnect(); await StreamVideo.reset(); if (context.mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => const LoginScreen(), ), ); } }, ), ], ), body: Center( child: ElevatedButton( onPressed: createLoadingText == null ? () async { setState(() => createLoadingText = 'Creating Livestream...'); await _createLivestream(); setState(() => createLoadingText = null); } : null, child: Text(createLoadingText ?? 'Create a Livestream'), ), ), ); } Future<void> _createLivestream() async { // Step 7 fills this in. } }
viewLoadingText is declared now but unused until Step 11 adds the second button.
Checkpoint: pick a user, press Login, and you land on the home screen with a "Create a Livestream" button and a logout icon. The button does nothing yet - Step 7 wires it up.
Step 7 - Create and Start a Livestream
Now, we can fill in the functionality to create a livestream whenever the button is pressed.
To create and start a livestream, we need to:
- Initialize a call instance with type
livestreamand a unique ID - Create the call on Stream's servers using
call.getOrCreate(), adding the current user as a host. - Update the call with backstage settings so viewers can see when the livestream will start.
- Configure and join the call with camera/microphone settings.
- Display the livestream UI by navigating to a new screen that will handle the video feed and controls
Here is what all of the above looks like in code. Replace the empty _createLivestream from Step 6 with this, and add the two imports at the top of lib/home_screen.dart:
123import 'dart:math'; import 'package:livestreaming_tutorial/livestream_screen.dart';
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374String _generateRandomCallId() { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; final random = Random(); return String.fromCharCodes( Iterable.generate( 6, (_) => chars.codeUnitAt(random.nextInt(chars.length)), ), ); } Future<void> _createLivestream() async { // Generate a random short call ID final callId = _generateRandomCallId(); // Set up our call object // The `broadcaster` audio policy optimizes audio for active publishing (the host), // enabling echo cancellation and noise suppression. It is also the default policy. final call = StreamVideo.instance.makeCall( callType: StreamCallType.liveStream(), id: callId, preferences: DefaultCallPreferences( audioConfigurationPolicy: const AudioConfigurationPolicy.broadcaster(), ), ); // Create the call and set the current user as a host final result = await call.getOrCreate( members: [ MemberRequest( userId: StreamVideo.instance.currentUser.id, role: 'host', ), ], ); if (result.isFailure) { debugPrint('Not able to create a call.'); return; } // Configure the call to allow users to join before it starts by setting a future start time // and specifying how many seconds in advance they can join via `joinAheadTimeSeconds` final updateResult = await call.update( startsAt: DateTime.now().toUtc().add(const Duration(seconds: 120)), backstage: const StreamBackstageSettings( enabled: true, joinAheadTimeSeconds: 120, ), ); if (updateResult.isFailure) { debugPrint('Not able to update the call.'); return; } // Set some default behavior for how our devices should be configured once we join a call final connectOptions = CallConnectOptions( camera: TrackOption.enabled(), microphone: TrackOption.enabled(), ); // Our local app user can join and receive events await call.join(connectOptions: connectOptions); if (!mounted) return; Navigator.of(context).push( MaterialPageRoute( builder: (context) => LiveStreamScreen(livestreamCall: call, callId: callId), ), ); }
For livestream calls the backstage mode is enabled by default. You can change it in the Stream Video Dashboard or by updating the call settings in the code.
12345await call.update( backstage: const StreamBackstageSettings( enabled: false, ), );
If backstage mode is enabled, the call hosts can join and see each other but the call will be invisible to others until call.goLive() is called.
To allow users to join the call before it starts, you can set the joinAheadTimeSeconds parameter when creating the call together with the startsAt parameter.
1234567await call.update( startsAt: DateTime.now().toUtc().add(const Duration(seconds: 120)), backstage: const StreamBackstageSettings( enabled: true, joinAheadTimeSeconds: 120, ), );
This will allow users to join the call 2 minutes before the livestream set start time.
User roles and permissions
For livestreaming, it's important to understand how roles and permissions work in Stream Video:
- 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(...)- as shown above where we assign thehostrole. - 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. If you want non-hosts to join early or wait backstage, adjust the permissions accordingly in the Dashboard.
Checkpoint: livestream_screen.dart doesn't exist yet, so the analyzer flags that import until Step 8. Once Step 8 lands, tapping "Create a Livestream" navigates to the new screen, which means getOrCreate(), update() and join() all succeeded. A 403 here almost always means the user role lacks CreateCall on the livestream call type.
Step 8 - Build the Livestream Screen
Now lets build the livestream screen for streaming that shows the live video feed and tracks viewer count in real-time. We will also create UI elements for possible states of the livestream, such as when it's not started yet and in the backstage mode, when it's live, or when it's ended.
To implement this, we'll create a widget that takes a livestream call object as a parameter.
By using the PartialCallStateBuilder, our widget can react to relevant changes in the livestream state, such as the livestream being in backstage or ended.
Let's create a new file called livestream_screen.dart and add the following code to implement our livestream screen. The three placeholder widgets at the bottom are filled in by Step 9:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import 'dart:async'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class LiveStreamScreen extends StatefulWidget { const LiveStreamScreen({ super.key, required this.livestreamCall, required this.callId, }); final Call livestreamCall; final String callId; State<LiveStreamScreen> createState() => _LiveStreamScreenState(); } class _LiveStreamScreenState extends State<LiveStreamScreen> { Widget build(BuildContext context) { return PartialCallStateBuilder( call: widget.livestreamCall, selector: (state) => (isBackstage: state.isBackstage, endedAt: state.endedAt), builder: (context, callState) { return Scaffold( body: Builder( builder: (context) { if (callState.isBackstage) { return BackstageWidget( call: widget.livestreamCall, callId: widget.callId, ); } if (callState.endedAt != null) { return LivestreamEndedWidget(call: widget.livestreamCall); } return LivestreamLiveWidget( call: widget.livestreamCall, callId: widget.callId, ); }, ), ); }, ); } } // Step 9 replaces these three. class BackstageWidget extends StatelessWidget { const BackstageWidget({super.key, required this.call, required this.callId}); final Call call; final String callId; Widget build(BuildContext context) => const Placeholder(); } class LivestreamLiveWidget extends StatelessWidget { const LivestreamLiveWidget({ super.key, required this.call, required this.callId, }); final Call call; final String callId; Widget build(BuildContext context) => const Placeholder(); } class LivestreamEndedWidget extends StatelessWidget { const LivestreamEndedWidget({super.key, required this.call}); final Call call; Widget build(BuildContext context) => const Placeholder(); }
This screen uses a PartialCallStateBuilder to reactively update the UI based on changes in the livestream state. The next step is to implement the different elements of the screen which will display the backstage environment and the livestream video feed.
Checkpoint: tapping "Create a Livestream" now lands on a crosshatched placeholder, which means isBackstage is true and PartialCallStateBuilder picked the backstage branch.
Step 9 - Build the Backstage, Live and Ended States
Now replace the three placeholders from Step 8 with the real widgets.
We'll start with the BackstageWidget which will display a countdown timer and a message indicating that the livestream is starting soon and the number of participants waiting to join.
We'll also add a button to transition the call from backstage mode to live mode or to leave the call. We will also display the call ID to allow the user to share it with others.
By calling call.goLive() the call will transition from backstage mode to live mode and allow other participants to join the call.
By default users can only join live calls. If you want to allow users to join before the livestream starts, you can set the joinAheadTimeSeconds parameter when creating the call.
All permissions can also be adjusted in the Stream Video Dashboard.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879class BackstageWidget extends StatelessWidget { const BackstageWidget({super.key, required this.call, required this.callId}); final Call call; final String callId; Widget build(BuildContext context) { return PartialCallStateBuilder( call: call, selector: (state) => state.callParticipants.where((p) => !p.roles.contains('host')).length, builder: (context, waitingParticipantsCount) { return Center( child: Column( spacing: 20, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300), ), child: Column( children: [ Text( 'Call ID', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 4), Text( callId, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, letterSpacing: 2, ), ), ], ), ), PartialCallStateBuilder( call: call, selector: (state) => state.startsAt, builder: (context, startsAt) { return Text( startsAt != null ? 'Livestream starting at ${DateFormat('HH:mm').format(startsAt.toLocal())}' : 'Livestream starting soon', style: Theme.of(context).textTheme.titleLarge, ); }, ), if (waitingParticipantsCount > 0) Text('$waitingParticipantsCount participants waiting'), const SizedBox(height: 30), ElevatedButton( onPressed: () { call.goLive(); }, child: const Text('Go Live'), ), ElevatedButton( onPressed: () { call.leave(); Navigator.pop(context); }, child: const Text('Leave Livestream'), ), ], ), ); }, ); } }
Next, the LivestreamLiveWidget which will display the host's livestream video feed and controls:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960class LivestreamLiveWidget extends StatelessWidget { const LivestreamLiveWidget({ super.key, required this.call, required this.callId, }); final Call call; final String callId; Widget build(BuildContext context) { return StreamCallContainer( call: call, callContentWidgetBuilder: (context, call) { return PartialCallStateBuilder( call: call, selector: (state) => state.callParticipants .where((e) => e.roles.contains('host')) .toList(), builder: (context, hosts) { if (hosts.isEmpty) { return const Center( child: Text("The host's video is not available"), ); } return StreamCallContent( call: call, callAppBarWidgetBuilder: (context, call) => CallAppBar( call: call, showBackButton: false, title: Column( mainAxisSize: MainAxisSize.min, children: [ PartialCallStateBuilder( call: call, selector: (state) => state.callParticipants.length, builder: (context, count) => Text('Viewers: $count'), ), Text( 'Call ID: $callId', style: Theme.of(context).textTheme.bodySmall, ), ], ), onLeaveCallTap: () { call.stopLive(); }, ), callParticipantsWidgetBuilder: (context, call) { return StreamCallParticipants(call: call, participants: hosts); }, ); }, ); }, ); } }
Finally, let's implement the LivestreamEndedWidget which will display a message indicating that the livestream has ended and a list of recordings. Please note that we did not add a way to end the livestream, only to leave it. To end the livestream you call call.end();.
Note that this one is a StatefulWidget, so it replaces the stateless placeholder from Step 8:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475class LivestreamEndedWidget extends StatefulWidget { const LivestreamEndedWidget({super.key, required this.call}); final Call call; State<LivestreamEndedWidget> createState() => _LivestreamEndedWidgetState(); } class _LivestreamEndedWidgetState extends State<LivestreamEndedWidget> { late Future<Result<List<CallRecording>>> _recordingsFuture; void initState() { super.initState(); _recordingsFuture = widget.call.listRecordings(); } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( automaticallyImplyLeading: false, leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { widget.call.leave(); Navigator.pop(context); }, ), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Livestream has ended'), FutureBuilder( future: _recordingsFuture, builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isSuccess) { final recordings = snapshot.requireData.getDataOrNull(); if (recordings == null || recordings.isEmpty) { return const Text('No recordings found'); } return Column( children: [ const Text('Watch recordings'), ListView.builder( shrinkWrap: true, itemCount: recordings.length, itemBuilder: (context, index) { final recording = recordings[index]; return ListTile( title: Text(recording.url), onTap: () { // open }, ); }, ), ], ); } return const SizedBox.shrink(); }, ), ], ), ), ); } }
Make sure the host role has permissions to list the recordings in Stream Dashboard if you want to display them in the app.
To start recording during the call you can use call.startRecording() method.
Alternatively you can set the recordings mode to auto in the Stream Video Dashboard, or by updating the call settings in the code.
If all works as intended, we will be able to create a livestream from the first device:

Human checkpoint: the backstage screen shows a Call ID, a start time and a Go Live button. Write that Call ID down - Steps 10 and 11 need it. Tap Go Live and your own camera feed fills the screen with Viewers: 1 in the app bar. Agents: build and launch, then ask the human to grant camera and microphone access and confirm the feed renders.
Step 10 - Watch the Livestream in a Browser
Stream uses a technology called SFU cascading to replicate your livestream over different SFUs around the world. This makes it possible to reach a large audience in realtime.
To view the livestream for testing, click Create a Livestream in the Flutter app, press Go Live, then click the link below to watch the video in your browser:
Your app generates a random call id each time, while the Join Call link above points at a pre-made tutorial call. Read the Call ID off the backstage screen from Step 9 and swap it into the last path segment of the Join Call URL - .../viewers/webrtc/<call-id>?api_key=... - leaving the query string as it is.
Using your own credentials from Option 1? The Join Call button above joins the shared tutorial environment, not your app. Run the app on a second device with a different user id and token, and the same call id. Two clients signed in as the same user count as one participant.
Checkpoint: the browser tab plays your device's camera and the app bar count moves to Viewers: 2. Keep that tab open.
This is the payoff: you're broadcasting live from a phone to anyone with the link. On the shared tutorial credentials? Create your own Stream app - the free maker plan covers hobby projects, and Step 3 swaps the credentials over in a few commands.
Step 11 - Watch the Livestream in the App
If you want to view the livestream through a Flutter application, you can use the LivestreamPlayer widget that is built into the Flutter SDK.
Let's add a second button in the home screen to allow users to view the livestream. This replaces the body of the Scaffold in _HomeScreenState.build() - the appBar from Step 6 stays exactly as it is:
12345678910111213141516171819202122232425262728body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, spacing: 16, children: [ ElevatedButton( onPressed: createLoadingText == null ? () async { setState(() => createLoadingText = 'Creating Livestream...'); await _createLivestream(); setState(() => createLoadingText = null); } : null, child: Text(createLoadingText ?? 'Create a Livestream'), ), ElevatedButton( onPressed: viewLoadingText == null ? () async { setState(() => viewLoadingText = 'Joining Livestream...'); await _viewLivestream(); setState(() => viewLoadingText = null); } : null, child: Text(viewLoadingText ?? 'View a Livestream'), ), ], ), ),
And implement the _viewLivestream method, alongside a small dialog that asks for the call id:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576Future<void> _viewLivestream() async { // Show dialog to get call ID from user final callId = await _showCallIdDialog(); if (callId == null || callId.isEmpty) { return; } // Set up our call object // The `viewer` audio policy optimizes audio for passive playback (watch-only). final call = StreamVideo.instance.makeCall( callType: StreamCallType.liveStream(), id: callId, preferences: DefaultCallPreferences( audioConfigurationPolicy: const AudioConfigurationPolicy.viewer(), ), ); final result = await call.getOrCreate(); // Call object is created if (result.isSuccess && mounted) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => Scaffold( appBar: AppBar( title: const Text('Livestream'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { call.leave(); Navigator.of(context).pop(); }, ), ), body: LivestreamPlayer( call: call, joinBehaviour: LivestreamJoinBehaviour.autoJoinAsap, connectOptions: CallConnectOptions( camera: TrackOption.disabled(), microphone: TrackOption.disabled(), ), ), ), ), ); } else { debugPrint('Not able to create a call.'); } } Future<String?> _showCallIdDialog() async { final controller = TextEditingController(); return showDialog<String>( context: context, builder: (context) => AlertDialog( title: const Text('Enter Call ID'), content: TextField( controller: controller, decoration: const InputDecoration( hintText: 'Enter the livestream call ID', border: OutlineInputBorder(), ), autofocus: true, ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(controller.text.trim()), child: const Text('Join'), ), ], ), ); }
With this implementation the user will be able to provide the call id and see our default UI for a livestream viewer with a back button to go back to the home screen.
The LivestreamPlayer widget has most required controls and info for viewing a livestream and makes your job to create a livestream viewing interface effortless.
You can use joinBehaviour to control when the user will join the call. By default it is LivestreamJoinBehaviour.autoJoinAsap, which means the user will join the call as soon as he can, depending on his permissions and livestream settings.
If you want to control when the user will join the call, you can set it to LivestreamJoinBehaviour.manualJoin and use the join method to join the call manually.
For testing, make sure to log in as two different users on separate devices - one acting as the host and the other as the viewer.
Checkpoint: log in as a second user on another device, tap "View a Livestream", paste the Call ID from Step 9, and LivestreamPlayer plays the host's feed. On the shared tutorial credentials you only have one user, so use the browser from Step 10 as your viewer instead.
Step 12 - Handle Reconnection Failures
Livestreaming depends on many factors, such as the network conditions on both the user publishing the stream, as well as the viewers.
A proper error handling is needed, to be transparent to 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 disconnected. 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 how to do that, by listening to the status change in initState of the LiveStreamScreen widget. This is a merge into the _LiveStreamScreenState you wrote in Step 8, not a whole-file replacement - dart:async is already imported at the top of the file:
123456789101112131415161718192021222324252627class _LiveStreamScreenState extends State<LiveStreamScreen> { late StreamSubscription<CallState> _callStateSubscription; void initState() { super.initState(); _callStateSubscription = widget.livestreamCall.state.valueStream .distinct((previous, current) => previous.status != current.status) .listen((event) { if (event.status is CallStatusDisconnected) { // Prompt the user to check their internet connection } }); } void dispose() { _callStateSubscription.cancel(); super.dispose(); } Widget build(BuildContext context) { // Unchanged from Step 8. } }
Checkpoint: with the livestream live, turn off wifi and cellular on the device. Put a debugPrint inside the CallStatusDisconnected branch to see it fire, then turn the network back on and the SDK reconnects on its own.
Step 13 (Optional) - Broadcast to HLS
Stream offers two flavors of livestreaming, WebRTC-based livestreaming and RTMP-based livestreaming. WebRTC based livestreaming allows users to easily start a livestream directly from their phone and benefit from ultra low latency.
The final piece of livestreaming using Stream is support for HLS or HTTP Live Streaming. HLS, unlike WebRTC based streaming, tends to have a 10 to 20 second delay but offers video buffering under poor network conditions.
To enable HLS support, your call must first be placed into "broadcasting" mode using the call.startHLS() method.
We can then obtain the HLS URL by querying the hlsPlaylistUrl from call.state. Add this behind a button in LivestreamLiveWidget:
1234567final result = await call.startHLS(); if (result.isSuccess) { final hlsUrl = call.state.value.egress.hlsPlaylistUrl; debugPrint('HLS playlist URL: $hlsUrl'); }
startHLS() returns a Result<String?> carrying the same URL, so result.getDataOrNull() works just as well as reading it back off call.state.
With the HLS URL, your call can be broadcast to most livestreaming platforms.
Checkpoint: startHLS() returns success and the URL ends in .m3u8. Paste it into VLC or Safari and your livestream plays, 10 to 20 seconds behind the WebRTC view.
Step 14 (Optional) - Ingest RTMP from OBS

For more advanced livestreaming configurations such as cases where multiple cameras may be required or different scenes and animations, streaming tools like OBS can be used together with Stream video using RTMP (Real Time Messaging Protocol).
By default, when a call is created, it is given a dedicated RTMP URL which can be used by most common streaming platforms to inject video into the call. To configure RTMP and OBS with Stream, two things are required:
- The RTMP URL of the call
- A "streaming key" comprised of your application's API Key and User Token in the format
apikey/usertoken
With these two pieces of information, we can update the settings in OBS then select the "Start Streaming" option to view our livestream in the application.
A user with the name and user token provided to OBS will appear in the call. It is worth creating a dedicated user object for OBS streaming.
Human checkpoint: OBS's Start Streaming button switches to Stop Streaming, and the OBS video appears in the call as a participant named after the user whose token you put in the stream key. Agents: this step is OBS-only - there is no CLI or API path for it. Ask the human to run it, or skip it.
Advanced Features
This tutorial covered the steps required to publish a livestream from a Flutter app and how to watch it from a browser or a second device. Several advanced features can improve the live streaming experience.
- Co-hosts You can add members to your livestream with elevated permissions. So you can have co-hosts, moderators, etc. You can see how to render multiple video tracks in our video calling tutorial.
- Permissions and Moderation. You can set up different permissions for different types of users and grant additional access using a request-based approach.
- Custom events: You can use custom events on the call to share any additional data. For example, you could show the score for a game or any other realtime use case.
- Reactions & Chat Users can react to the livestream, and you can add chat. This makes for a more engaging experience.
- Notifications You can notify users via push notifications when the livestream starts
- Recording The call recording functionality allows you to record the call with various options and layouts
- Transcriptions Transcriptions can be a great addition to livestreams, especially for users that have muted their audio.
- Noise cancellation Noise cancellation can enhance the quality of the livestreaming experience.
- HLS Another way to watch a livestream is using HLS. HLS tends to have a 10 to 20-second delay, while the WebRTC approach is realtime. The benefit that HLS offers is better buffering under poor network conditions.
Verify the whole build
Build to your device and run the full loop:
1flutter run
Log in as the first user, tap "Create a Livestream", and allow camera and microphone access. Confirm the backstage screen shows a Call ID and a start time, then tap Go Live and check your own feed renders with Viewers: 1. Open the browser viewer with that call id and watch the count go to 2. Log in as a second user on another device, tap "View a Livestream", paste the call id, and confirm LivestreamPlayer plays the host's feed. Leave the livestream, land back on the home screen, and log out.
The shared tutorial credentials from Step 3 give you one user, not three, so the in-app viewer pass needs your own credentials from Option 1. On tutorial credentials, the browser tab is your second participant and everything else in the loop still applies.
Troubleshooting
Invalid version constraintfromflutter pub get- the^latestplaceholders inpubspec.yamlare not real constraints. Replace them with the current version from pub.dev (Step 2).project credentials missingorstream project is not initialized- the CLI has not been initialized here. Rungetstream initin the project directory first.token is invalid/ auth error at login - the token was minted for a different app, or it expired. Re-mint withgetstream token <user-id>and confirm the API key inapp_keys.dartmatches.403when tapping "Create a Livestream" - theuserrole lacksCreateCallon thelivestreamcall type. The creator is added withrole: 'host'in Step 7; check Roles & Permissions in the dashboard.- The viewer's
join()fails while the livestream is in backstage - only hosts can join before it goes live. Press Go Live on the host device, or setjoinAheadTimeSecondstogether withstartsAt(Step 7). - Stuck on the backstage screen -
goLive()never succeeded. Check the console; the host needs a role with permission to go live. - The browser shows nothing - the Join Call URL points at the shared tutorial call id, not the random one your app generated. Read the Call ID off the backstage screen and swap it into the URL (Step 10).
- Viewer count stays at 1 - two sessions signed in as the same user count as one participant. Log in as a different
TutorialUseron the second device. - No video, only a black tile - the iOS Simulator has no camera, so build to a physical device; on Android check the emulator's AVD has a camera configured rather than
none. - Camera and mic permission prompts never appear - the usage description keys are missing from
Info.plist, or the permissions are missing fromAndroidManifest.xml(Step 2). - No recordings on the ended screen - the
hostrole needs permission to list recordings, and recording has to have run. See the note in Step 9. The package product 'stream-video-flutter' requires minimum platform version 14.0- the iOS deployment target is still at theflutter createdefault of 13.0. Raise it to 14.0 (Step 2). Recent Flutter versions use Swift Package Manager and generate noPodfile, so the fix lives in the Xcode project, not aPodfile.- iOS build fails to find a pod - only applies if your project still uses CocoaPods (there is an
ios/Podfile). Runpod installfrom theiosfolder, orflutter cleanfollowed byflutter run. On a Swift Package Manager project,flutter cleanand rebuild instead. - iOS device build fails to sign - open
ios/Runner.xcworkspacein Xcode, pick a Team under Signing & Capabilities, and change the bundle identifier if the default is taken.
Recap
Stream Video allows you to quickly build in-app low-latency livestreaming in Flutter. Our team is happy to review your UI designs and offer recommendations on how to achieve it with the Stream SDKs.
To recap what we've learned:
- WebRTC is optimal for latency, while HLS is slower, but buffers better for users with poor connections.
- You set up a call with
final call = StreamVideo.instance.makeCall(callType: StreamCallType.liveStream(), id: callID). - The call type
livestreamcontrols which features are enabled and how permissions are set up. - The livestream call has backstage mode enabled by default. This allows you and your co-hosts to setup your mic and camera before allowing people in.
- When you join a call, realtime communication is setup for audio & video:
call.join(), remember to set theconnectOptionsappropriately. - Data in
call.stateandcall.state.value.callParticipantsmake it easy to build your own UI, andPartialCallStateBuilderrebuilds only the widgets whose slice of that state changed.
Calls run on Stream's global edge network of video servers. Being closer to your users improves the latency and reliability of calls. The SDKs enable you to build livestreaming, audio rooms and video calling in days.
Find the complete code for this tutorial on the Flutter Video Tutorials Repository.
We hope you've enjoyed this tutorial and please feel free to reach out if you have any suggestions or questions.
Next steps
- Video calling - the same
Callobject, in a two-way call: video calling tutorial - Audio rooms - a Clubhouse-style experience with backstage and raised hands: audio room tutorial
- Ringing & CallKit - native incoming-call screens and VoIP push: ringing tutorial
- Chat alongside the stream - the Chat Flutter SDK drops into the same screen: chat integration guide
- Recording & broadcasting - record your livestreams and broadcast them to HLS
- Stream discovery - list upcoming, live and finished livestreams with querying calls
- Permissions and moderation - permissions and moderation for roles beyond host and viewer
- Call and participant state - the full state model: guide
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
In this video app tutorial we built a fully functioning Flutter livestreaming app with our Flutter SDK component library. We also showed how easy it is to customize the behavior and the style of the Flutter video app components with minimal code changes.
Both the video SDK for Flutter and the API have plenty more features available to support more advanced use-cases.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skills --universalandgetstream skills stream-flutter --universalfor the Flutter pack (use--claudeinstead for Claude Code). (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream open(dashboard, to read the API key) ->getstream token <user-id>(mint one token per user; this tutorial uses three); pick any URL-safe call id - Call type:
livestream- starts in backstage, and onlyhostandadminmay create a call or join before it goes live. The creator is added withrole: 'host'. Both defaults are editable per call type. - Manual steps with no CLI or agent path: the OBS/RTMP route in Step 14
- Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, calls, and call members - Flutter integration skill: invoke
/stream-flutterin your agent for Flutter setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/flutter/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

