Platform docs
Auth, users, webhooks & more

Hosting a Livestream

This cookbook builds a complete host screen for a livestream: the host gets their own camera preview, can toggle and flip devices while in backstage, goes live, sees how many viewers are watching, and ends the stream cleanly. For the concepts behind each step, see the Livestreaming guide. For the viewer side, see Watching a Livestream.

Info:

For comprehensive guidance on mobile livestreaming performance, see our Mobile Livestreaming Broadcasting Guide. This guide covers critical considerations for Flutter apps on both iOS and Android, including battery optimization, thermal management, network strategies, and app performance during streaming.

Prerequisites

  • stream_video_flutter installed and the platform permissions declared, see Installation.
  • A connected StreamVideo client, see Quickstart.
  • The host's user is allowed to create livestream calls. This screen adds the user as a member with the host role; check the livestream call type's roles and permissions in the Stream Dashboard.
  • permission_handler (or another way to request runtime permissions) for camera and microphone.

What the host screen does

The host flow has a different shape from the viewer flow, so it gets its own screen instead of sharing the viewer's widgets:

Step Host Viewer
Permissions Camera + microphone, before joining None
Create / load call getOrCreate(members: [... role: 'host']) getOrCreate()
Join join(); camera and microphone follow the call type settings join() with tracks disabled (or LivestreamPlayer)
Video shown state.localParticipant (own preview) Remote participant with the host role
Controls Mic, camera, flip, Go Live / Stop Live, End None
Leaving end() then leave(); leave() in dispose() leave()

Starting the stream

The screen requests permissions, creates the call as host and joins with the camera and microphone on. Every step returns early with an error message if it fails:

import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:stream_video_flutter/stream_video_flutter.dart';

class HostLivestreamScreen extends StatefulWidget {
  const HostLivestreamScreen({super.key, required this.callId});

  final String callId;

  @override
  State<HostLivestreamScreen> createState() => _HostLivestreamScreenState();
}

class _HostLivestreamScreenState extends State<HostLivestreamScreen> {
  Call? _call;
  String? _error;

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

  Future<void> _start() async {
    // 1. Runtime permissions (Android 6+ and iOS).
    final statuses = await [
      Permission.camera,
      Permission.microphone,
    ].request();
    if (!statuses.values.every((status) => status.isGranted)) {
      return _fail('Camera and microphone permissions are required to host.');
    }

    // 2. Create the call with the current user as host.
    final call = StreamVideo.instance.makeCall(
      callType: StreamCallType.liveStream(),
      id: widget.callId,
    );

    final createResult = await call.getOrCreate(
      members: [
        MemberRequest(
          userId: StreamVideo.instance.currentUser.id,
          role: 'host',
        ),
      ],
    );
    if (createResult.isFailure) {
      return _fail('Could not create: ${createResult.getErrorOrNull()?.message}');
    }

    // 3. Join. Camera and microphone follow the livestream call type's
    // settings in the Stream Dashboard.
    final joinResult = await call.join(
      // Optional: hint that this participant publishes to a large audience.
      // hintHighScaleLivestreamPublisher: true,
    );
    if (joinResult.isFailure) {
      return _fail('Could not join: ${joinResult.getErrorOrNull()?.message}');
    }

    // The screen may have been closed while joining.
    if (!mounted) {
      await call.leave();
      return;
    }

    setState(() => _call = call);
  }

  void _fail(String message) {
    if (mounted) setState(() => _error = message);
  }

  @override
  void dispose() {
    // Safety net: never keep publishing after the screen is gone.
    // leave() is safe to call after end() or a previous leave().
    _call?.leave();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: SafeArea(
        child: switch ((_call, _error)) {
          (_, final String error) => Center(
              child: Text(error, style: const TextStyle(color: Colors.white)),
            ),
          (final Call call, _) => HostLivestreamView(call: call),
          _ => const Center(child: CircularProgressIndicator()),
        },
      ),
    );
  }
}

Whether the camera and microphone start on when the host joins is set by the livestream call type's settings (camera default on, mic default on) in the Stream Dashboard. Only pass connectOptions to join() if your app lets the host choose before joining, for example with toggles on a pre-join screen, see Create the call and join as host.

The host view

Once joined, the host sees their own camera through state.localParticipant, with a status bar on top and the controls at the bottom. The selector only picks what this view needs, so it rebuilds only when one of those values changes. It is a static method rather than an inline closure, because PartialCallStateBuilder subscribes again whenever its selector changes, and an inline closure is a new function on every rebuild:

class HostLivestreamView extends StatelessWidget {
  const HostLivestreamView({super.key, required this.call});

  final Call call;

  static ({
    CallParticipantState? localParticipant,
    bool isBackstage,
    int viewerCount,
  }) _selectHostView(CallState state) => (
        localParticipant: state.localParticipant,
        isBackstage: state.isBackstage,
        viewerCount: state.callParticipants
            .where((p) => !p.roles.contains('host'))
            .length,
      );

  @override
  Widget build(BuildContext context) {
    return PartialCallStateBuilder(
      call: call,
      selector: _selectHostView,
      builder: (context, data) {
        final localParticipant = data.localParticipant;

        return Stack(
          children: [
            Positioned.fill(
              child: localParticipant == null
                  ? const Center(child: CircularProgressIndicator())
                  : StreamCallParticipant(
                      call: call,
                      participant: localParticipant,
                      videoFit: VideoFit.cover,
                      showConnectionQualityIndicator: false,
                      showParticipantLabel: false,
                      showSpeakerBorder: false,
                    ),
            ),
            Positioned(
              top: 16,
              left: 16,
              right: 16,
              child: HostStatusBar(
                call: call,
                isBackstage: data.isBackstage,
                viewerCount: data.viewerCount,
              ),
            ),
            Positioned(
              left: 16,
              right: 16,
              bottom: 24,
              child: HostControls(call: call, isBackstage: data.isBackstage),
            ),
          ],
        );
      },
    );
  }
}

state.callParticipants contains the host as well, so the viewer count leaves out participants with the host role.

Status bar

The status bar shows whether the host is in backstage or live, how long the stream has been live, and the viewer count:

class HostStatusBar extends StatelessWidget {
  const HostStatusBar({
    super.key,
    required this.call,
    required this.isBackstage,
    required this.viewerCount,
  });

  final Call call;
  final bool isBackstage;
  final int viewerCount;

  @override
  Widget build(BuildContext context) {
    const textStyle = TextStyle(color: Colors.white);

    return Row(
      children: [
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          decoration: BoxDecoration(
            color: isBackstage ? Colors.grey : Colors.red,
            borderRadius: BorderRadius.circular(4),
          ),
          child: Text(isBackstage ? 'BACKSTAGE' : 'LIVE', style: textStyle),
        ),
        const SizedBox(width: 12),
        if (!isBackstage)
          StreamBuilder<Duration>(
            stream: call.callDurationStream,
            builder: (context, snapshot) {
              final duration = snapshot.data ?? Duration.zero;
              final minutes = duration.inMinutes;
              final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
              return Text('$minutes:$seconds', style: textStyle);
            },
          ),
        const Spacer(),
        const Icon(Icons.visibility, color: Colors.white, size: 16),
        const SizedBox(width: 4),
        Text('$viewerCount', style: textStyle),
      ],
    );
  }
}

Host controls

The controls toggle the microphone and camera, flip the camera, go live or back to backstage, and end the stream. They live only in the host screen, so viewers who are allowed into backstage never see a "Go Live" button.

class HostControls extends StatelessWidget {
  const HostControls({super.key, required this.call, required this.isBackstage});

  final Call call;
  final bool isBackstage;

  static ({bool isCameraEnabled, bool isMicrophoneEnabled}) _selectDevices(
    CallState state,
  ) =>
      (
        isCameraEnabled: state.localParticipant?.isVideoEnabled ?? false,
        isMicrophoneEnabled: state.localParticipant?.isAudioEnabled ?? false,
      );

  @override
  Widget build(BuildContext context) {
    return PartialCallStateBuilder(
      call: call,
      selector: _selectDevices,
      builder: (context, devices) {
        return Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            IconButton.filledTonal(
              icon: Icon(devices.isMicrophoneEnabled ? Icons.mic : Icons.mic_off),
              onPressed: () => call.setMicrophoneEnabled(
                enabled: !devices.isMicrophoneEnabled,
              ),
            ),
            IconButton.filledTonal(
              icon: Icon(
                devices.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
              ),
              onPressed: () => call.setCameraEnabled(
                enabled: !devices.isCameraEnabled,
              ),
            ),
            IconButton.filledTonal(
              icon: const Icon(Icons.flip_camera_ios),
              onPressed: call.flipCamera,
            ),
            FilledButton(
              onPressed: () => isBackstage
                  ? _run(context, call.goLive(), 'go live')
                  : _run(context, call.stopLive(), 'stop the livestream'),
              child: Text(isBackstage ? 'Go Live' : 'Stop Live'),
            ),
            IconButton.filled(
              style: IconButton.styleFrom(backgroundColor: Colors.red),
              icon: const Icon(Icons.call_end),
              onPressed: () => _endLivestream(context),
            ),
          ],
        );
      },
    );
  }

  Future<void> _run(
    BuildContext context,
    Future<Result<Object?>> action,
    String label,
  ) async {
    final result = await action;
    if (result.isFailure && context.mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Could not $label: ${result.getErrorOrNull()?.message}'),
        ),
      );
    }
  }

  Future<void> _endLivestream(BuildContext context) async {
    // end() only succeeds while the call is active (joined).
    // Otherwise it returns a failure and the call is not ended.
    await _run(context, call.end(), 'end the livestream');

    // Always leave, also when end() failed.
    await call.leave();

    if (context.mounted) Navigator.of(context).pop();
  }
}

A few details worth calling out:

  • The mic and camera toggles pass the negated current state (!devices.isMicrophoneEnabled). Passing the current value would leave the device unchanged.
  • goLive() can start more than the WebRTC stream. Pass startHls: true, startRecording: true or startTranscription: true to start those at the same time, see Going live and stopping.
  • stopLive() versus end(). stopLive() puts the call back in backstage, so the host can go live again. end() ends the call for everyone: viewers' calls disconnect with DisconnectReasonEnded and nobody can rejoin.
  • end() then leave(). end() returns a failure when the call's status is not active. Calling leave() afterwards always releases the camera and microphone, and the leave() in dispose() covers the host closing the screen without pressing End.

Before going live

While the call is in backstage, only users with the join-backstage capability can join, which by default means hosts. Use this time to check the preview, framing and audio.

If you set joinAheadTimeSeconds on the call's backstage settings, viewers can join before goLive(), and they receive the host's audio and video as soon as they join. If you don't want a pre-show, keep joinAheadTimeSeconds at 0. Otherwise the host has to keep the camera and microphone off with the controls below until they go live. Note that with the camera off, the host's own preview is empty too.

Opening the host screen

Push the screen with the id of the livestream to host. Viewers open the same call id with the viewer UI from Watching a Livestream:

Navigator.of(context).push(
  MaterialPageRoute(
    builder: (_) => const HostLivestreamScreen(callId: 'my-livestream'),
  ),
);

Viewers can find streams that are live with queryCalls, see Finding live streams.