Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

Flutter Audio Room Tutorial

The following tutorial shows you how to quickly build an Audio Room app leveraging Stream's Video API and the Stream Video Flutter components. The underlying API is very flexible and allows you to build nearly any type of video experience.

example of flutter video and audio sdk

In this tutorial, we will learn how to build an audio room experience similar to Twitter Spaces or Clubhouse using Stream Video. The end result will support the following features:

  • Backstage mode. You can start the call with your co-hosts and chat a bit before going live.
  • Calls run on Stream's global edge network for optimal latency and scalability.
  • There is no cap to how many listeners you can have in a room.
  • Listeners can raise their hand, and be invited to speak by the host.
  • Audio tracks are sent multiple times for optimal reliability.
  • UI components are fully customizable, as demonstrated in the Flutter Video Cookbook.

You can find the full code for the video calling tutorial on the Flutter Video Tutorials repository.

Let's dive in! If you have any questions or need to provide feedback along the way, don't hesitate to use the feedback button - we're here to help!

Step 1 - Create a new project and add configuration

Let’s begin by creating a new Flutter project:

bash
1
2
flutter create audioroom_tutorial --empty cd audioroom_tutorial

The next step is to add the Flutter SDK for Stream Video to your dependencies. Open pubspec.yaml and add the following inside the dependencies section:

yaml
1
2
3
4
5
6
7
dependencies: flutter: sdk: flutter stream_video: ^latest stream_video_flutter: ^latest stream_video_push_notification: ^latest

Stream has several packages that you can use to integrate video into your application.

In this tutorial, we will use the stream_video_flutter package which contains pre-built UI elements for you to use.

You can also use the stream_video package directly if you need direct access to the low-level client.

The stream_video_push_notification package helps in adding push notifications and an end-to-end call flow (CallKit).

Before you go ahead, you need to add the required permissions for video calling to your app.

In your AndroidManifest.xml file, add these permissions:

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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 the corresponding iOS permissions, open the Info.plist file and add:

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<key>NSCameraUsageDescription</key> <string>$(PRODUCT_NAME) Camera Usage!</string> <key>NSMicrophoneUsageDescription</key> <string>$(PRODUCT_NAME) Microphone Usage!</string> <key>UIApplicationSupportsIndirectInputEvents</key> <true/> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>fetch</string> <string>processing</string> <string>remote-notification</string> <string>voip</string> </array>

Step 2 - Setting up the Stream Video client

To actually run this sample we need a valid user token. The user token is typically generated by your server side API. When a user logs in to your app you return the user token that gives them access to the call. To make this tutorial easier to follow we'll generate a user token for you:

Please update REPLACE_WITH_API_KEY, REPLACE_WITH_USER_ID, REPLACE_WITH_TOKEN, and REPLACE_WITH_CALL_ID (seen later) with the actual values:

Here are credentials to try out the app with:

PropertyValue
API KeyWaiting for an API key ...
Token Token is generated ...
User IDLoading ...
Call IDCreating random call ID ...
For testing you can join the call on our web-app: Join Call

First, let’s import the package into the project and then initialise the client with the credentials you received:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 'package:stream_video_flutter/stream_video_flutter.dart'; Future<void> main() async { // Ensure Flutter is able to communicate with Plugins WidgetsFlutterBinding.ensureInitialized(); // Initialize Stream video and set the API key for our app. StreamVideo( 'REPLACE_WITH_API_KEY', user: const User( info: UserInfo( name: 'John Doe', id: 'REPLACE_WITH_USER_ID', ), ), userToken: 'REPLACE_WITH_TOKEN', ); runApp( const MaterialApp( home: HomeScreen(), ), ); }

Step 3 - Building 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 an audio room, and another page to view and control the room. First, let's create a basic home screen with a button in the center that will eventually create an audio room:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, }); State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { Widget build(BuildContext context) { return Scaffold( body: Center( child: ElevatedButton( onPressed: () => _createAudioRoom(), child: const Text('Create an Audio Room'), ), ), ); } Future<void> _createAudioRoom() async {} }

Now, we can fill in the functionality to create a audio room whenever the button is pressed.

To do this, we have to do a few things:

  1. Create a call with a type of audio_room and pass in an ID for the call.
  2. Set any connect options required and call call.getOrCreate() to create the audio room.
  3. If call is successfully created, join the call and use call.goLive() to start the audio room immediately.
  4. Navigate to the page for displaying the audio room once everything is created properly.

⚠️ If you do not call call.goLive(), an audio_room call is started in backstage mode, meaning the call hosts can join and see each other but the call will be invisible to others.

Here is what all of the above looks like in code:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Future<void> _createAudioRoom() async { // Set up our call object final call = StreamVideo.instance.makeCall( callType: StreamCallType.audioRoom(), id: 'REPLACE_WITH_CALL_ID', ); final result = await call.getOrCreate(); // Call object is created if (result.isSuccess) { await call.join(); // Our local app user can join and receive events await call.goLive(); // Allow others to see and join the call (exit backstage mode) Navigator.of(context).push( MaterialPageRoute( builder: (context) => AudioRoomScreen( audioRoomCall: call, ), ), ); } else { debugPrint('Not able to create a call.'); } }

Step 4 - Building the audio room screen

For this example, the audio room screen we are making displays the current audio room participants. It also contains an option to leave the audio room and mute/unmute the local user.

First of all, let's create a basic audio room screen widget and accept the room call as a parameter. We can then listen to any state changes to this call via call.state.valueStream to respond to anything changing in the audio room call.

Here the code for the screen:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class AudioRoomScreen extends StatefulWidget { const AudioRoomScreen({ super.key, required this.audioRoomCall, }); final Call audioRoomCall; State<AudioRoomScreen> createState() => _AudioRoomScreenState(); } class _AudioRoomScreenState extends State<AudioRoomScreen> { late CallState _callState; void initState() { super.initState(); _callState = widget.audioRoomCall.state.value; } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Audio Room: ${_callState.callId}'), leading: IconButton( onPressed: () async { await widget.audioRoomCall.leave(); Navigator.of(context).pop(); }, icon: const Icon( Icons.close, ), ), ), body: StreamBuilder<CallState>( initialData: _callState, stream: widget.audioRoomCall.state.valueStream, builder: (context, snapshot) { // ... }, ), ); } }

In this code sample, we display the ID of the call using the existing CallState in an AppBar at the top of the Scaffold. Additionally, there is also a leading close action on the AppBar which leaves the audio room.

Next, inside the StreamBuilder, we can display the grid of participants if the state is retrieved correctly. If retrieval fails or is still in progress, we can display a failure message or a loading indicator respectively.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
StreamBuilder<CallState>( initialData: _callState, stream: widget.audioRoomCall.state.valueStream, builder: (context, snapshot) { if (snapshot.hasError) { return const Center( child: Text('Cannot fetch call state.'), ); } if (snapshot.hasData && !snapshot.hasError) { var callState = snapshot.data!; return GridView.builder( itemBuilder: (BuildContext context, int index) { return Align( widthFactor: 0.8, child: StreamCallParticipant( call: widget.audioRoomCall, backgroundColor: Colors.transparent, participant: callState.callParticipants[index], showParticipantLabel: true, showConnectionQualityIndicator: false, userAvatarTheme: const StreamUserAvatarThemeData( constraints: BoxConstraints.expand( height: 100, width: 100, ), ), ), ); }, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, ), itemCount: callState.callParticipants.length, ); } return const Center( child: CircularProgressIndicator(), ); }, ),

For displaying the participants, we are using the built-in StreamCallParticipant widget which displays the icon of the participants as well as their name and microphone enabled status.

Step 5 - Setting up audio and permissions

An audio room example would not be complete without the ability to control whether the user is speaking or not. For this, the setMicrophoneEnabled method can be invoked on the Call object. Let's use a FloatingActionButton to achieve this:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Inside state var microphoneEnabled = false; // Inside Scaffold floatingActionButton: FloatingActionButton( child: microphoneEnabled ? const Icon(Icons.mic) : const Icon(Icons.mic_off), onPressed: () { if (microphoneEnabled) { widget.audioRoomCall.setMicrophoneEnabled(enabled: false); setState(() { microphoneEnabled = false; }); } else { if (!widget.audioRoomCall.hasPermission(CallPermission.sendAudio)) { widget.audioRoomCall.requestPermissions( [CallPermission.sendAudio], ); } widget.audioRoomCall.setMicrophoneEnabled(enabled: true); setState(() { microphoneEnabled = true; }); } }, ),

If you look at the code snippet above, if the user presses the microphone icon, we first check if the user has permission to talk on the call using call.hasPermission(). This is important as most of users in an audio room are often listeners. If the user does not have permission to do this, we can request this via the call.requestPermissions() method:

dart
1
final result = await call.requestPermissions([CallPermission.sendAudio]);

The last part of this is approving or denying the permission requests sent by users. The hosts of the call will receive these permission requests via call.onPermissionRequest. We can add this to initState() on the audio room screen:

dart
1
2
3
4
5
6
widget.audioRoomCall.onPermissionRequest = (permissionRequest) { widget.audioRoomCall.grantPermissions( userId: permissionRequest.user.id, permissions: permissionRequest.permissions.toList(), ); };

For this tutorial, we are simply approving all incoming requests via call.grantPermissions() but for production apps, you can create a pop-up dialog for users or have your own implementation of the approval process.

With this, the final code of the audio room page becomes:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
class AudioRoomScreen extends StatefulWidget { const AudioRoomScreen({ super.key, required this.audioRoomCall, }); final Call audioRoomCall; State<AudioRoomScreen> createState() => _AudioRoomScreenState(); } class _AudioRoomScreenState extends State<AudioRoomScreen> { late CallState _callState; var microphoneEnabled = false; void initState() { super.initState(); _callState = widget.audioRoomCall.state.value; widget.audioRoomCall.onPermissionRequest = (permissionRequest) { widget.audioRoomCall.grantPermissions( userId: permissionRequest.user.id, permissions: permissionRequest.permissions.toList(), ); }; } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Audio Room: ${_callState.callId}'), leading: IconButton( onPressed: () async { await widget.audioRoomCall.leave(); Navigator.of(context).pop(); }, icon: const Icon( Icons.close, ), ), ), floatingActionButton: FloatingActionButton( child: microphoneEnabled ? const Icon(Icons.mic) : const Icon(Icons.mic_off), onPressed: () { if (microphoneEnabled) { widget.audioRoomCall.setMicrophoneEnabled(enabled: false); setState(() { microphoneEnabled = false; }); } else { if (!widget.audioRoomCall.hasPermission(CallPermission.sendAudio)) { widget.audioRoomCall.requestPermissions( [CallPermission.sendAudio], ); } widget.audioRoomCall.setMicrophoneEnabled(enabled: true); setState(() { microphoneEnabled = true; }); } }, ), body: StreamBuilder<CallState>( initialData: _callState, stream: widget.audioRoomCall.state.valueStream, builder: (context, snapshot) { if (snapshot.hasError) { return const Center( child: Text('Cannot fetch call state.'), ); } if (snapshot.hasData && !snapshot.hasError) { var callState = snapshot.data!; return GridView.builder( itemBuilder: (BuildContext context, int index) { return Align( widthFactor: 0.8, child: StreamCallParticipant( call: widget.audioRoomCall, backgroundColor: Colors.transparent, participant: callState.callParticipants[index], showParticipantLabel: true, showConnectionQualityIndicator: false, userAvatarTheme: const StreamUserAvatarThemeData( constraints: BoxConstraints.expand( height: 100, width: 100, ), ), ), ); }, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, ), itemCount: callState.callParticipants.length, ); } return const Center( child: CircularProgressIndicator(), ); }, ), ); } }

Now when you run the app, you will see a button to disable/enable the microphone and to leave the room.

To make this a little more interactive, let's join the audio room from your browser.

For testing you can join the call on our web-app: Join Call

If all works as intended, you will see an audio room with two participants:

By default the audio_room call type has backstage mode enabled. This makes it easy to try out your room and talk to your co-hosts before going live. In this tutorial, we called call.goLive() after creating the call which means the call is already live when you navigate to the audio room screen. You can enable/disable the usage of backstage mode in the dashboard.

Other built-in features

There are a few more exciting features that you can use to build audio rooms:

  • Requesting Permissions: Participants can ask the host for permission to speak, share video etc
  • Query Calls: You can query calls to easily show upcoming calls, calls that recently finished etc
  • Call Previews: Before you join the call you can observe it and show a preview. IE John, Sarah and 3 others are on this call.
  • Reactions & Custom events: Reactions and custom events are supported
  • Recording & Broadcasting: You can record your calls, or broadcast them to HLS
  • Chat: Stream's chat SDKs are fully featured and you can integrate them in the call
  • Moderation: Moderation capabilities are built-in to the product
  • Transcriptions: Transcriptions aren't available yet, but are coming soon

Recap

Find the complete code for this tutorial on the Flutter Video Tutorials Repository.

Stream Video allows you to quickly build a scalable audio-room experience for your app. Please do let us know if you ran into any issues while running this tutorial. Our team is also happy to review your UI designs and offer recommendations on how to achieve it with Stream.

To recap what we've learned:

  • You setup a call with var call = client.makeCall(callType: StreamCallType.audioRoom(),id: 'CALL_ID');.
  • The call type audio_room controls which features are enabled and how permissions are set up.
  • The audio_room by default enables backstage mode, and only allows admins to join before the call goes live.
  • When you join a call, realtime communication is setup for audio & video calling with call.join().
  • Data in call.state and call.state.value.participants make it easy to build your own UI.

Calls run on Stream's global edge network of video servers. Being closer to your users improves the latency and reliability of calls. For audio rooms we use Opus RED and Opus DTX for optimal audio quality.

The SDKs enable you to build audio rooms, video calling and livestreaming in days.

We hope you've enjoyed this tutorial, and please do feel free to reach out if you have any suggestions or questions.

Final Thoughts

In this video app tutorial we built a fully functioning Flutter messaging 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.

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.