Build a Flutter video chat app with Zoom Video SDK

Zoom Video SDK lets you add real-time audio and video to your application easily, backed by the powerful Zoom technology users love. In this guide, we'll explore using the Video SDK to create a video chat application with Flutter, an open source framework that simplifies building natively compiled multi-platform applications from a single codebase.

You can find the code for this blog on GitHub.

two screenshots: one screen to start a session and another screen with two users talking to each other

Prerequisites:

Step 1: Create a new Flutter project

To start, we'll create a new Flutter project. Open a terminal and execute:

$ flutter create zoom_flutter_hello_world
$ cd zoom_flutter_hello_world

This will create a new Flutter project with the following structure:

.
├── android
├── ios
├── lib
│   └── main.dart
├── pubspec.yaml
├── test
└── web

Step 2: Configuring the project

Install the dependencies
We will install the Zoom Video SDK for Flutter and the JWT package. Add the following to your pubspec.yaml file:

dependencies:
    flutter:
        sdk: flutter
    flutter_zoom_videosdk: ^1.7.0
    dart_jsonwebtoken: ^2.12.1

Run flutter pub get to install the dependencies.

Add permissions

For iOS, add the following to your ios/Runner/Info.plist file:

<key>NSCameraUsageDescription</key>
<string>For video conferencing</string>
<key>NSMicrophoneUsageDescription</key>
<string>For audio conferencing</string>

Config file

Let's create a config.dart file in the lib directory to store our Zoom SDK credentials and session details:

const Map configs = {
  'ZOOM_SDK_KEY': 'YOUR_ZOOM_SDK_KEY',
  'ZOOM_SDK_SECRET': 'YOUR_ZOOM_SDK_SECRET',
};
const Map sessionDetails = {
  'sessionName': 'TestOne',
  'sessionPassword': '',
  'displayName': 'Flutter',
  'sessionTimeout': '40',
  'roleType': '1',
};

Disclaimer: To demo the app, we'll be using the Zoom Video SDK credentials to generate a JWT from within the application. In production use-cases do not store your app key and secret inside your app. This is not a safe operation, instead use a server to generate the tokens and request them in your app instead.

Step 3: Setting up the main app

Let's update the main.dart file to initialize the Zoom Video SDK. We'll start by importing the necessary packages and creating the Videochat widget:

import 'package:flutter/material.dart';
import 'package:flutter_zoom_videosdk/native/zoom_videosdk.dart';
import 'package:zoom_flutter_hello_world/videochat.dart';
class ZoomVideoSdkProvider extends StatelessWidget {
  const ZoomVideoSdkProvider({super.key});
  @override
  Widget build(BuildContext context) {
    var zoom = ZoomVideoSdk();
    InitConfig initConfig = InitConfig(
      domain: "zoom.us",
      enableLog: true,
    );
    zoom.initSdk(initConfig);
    return const Videochat();
  }
}

We'll initialize the Zoom Video SDK in the ZoomVideoSdkProvider widget and return the Videochat widget.

We'll create the Videochat widget next. Let's update the main function to use the ZoomVideoSdkProvider widget:

void main() {
  runApp(
    MaterialApp(
      title: 'Zoom Flutter Hello World',
      home: const SafeArea(
        child: ZoomVideoSdkProvider(),
      ),
    ),
  );
}

Step 4: Building the video chat component

Now, let's create a new file lib/videochat.dart that will contain the video chat component. We'll add the imports for the different packages we'll be using and defined the Videochat widget that will render the video chat UI:

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:zoom_flutter_hello_world/config.dart';
import 'package:zoom_flutter_hello_world/utils/jwt.dart';
import 'package:flutter_zoom_videosdk/native/zoom_videosdk.dart';
import 'package:flutter_zoom_videosdk/native/zoom_videosdk_user.dart';
import 'package:flutter_zoom_videosdk/native/zoom_videosdk_event_listener.dart';
import 'package:flutter_zoom_videosdk/flutter_zoom_view.dart' as zoom_view;
class Videochat extends StatefulWidget {
  const Videochat({super.key});
  @override
  State<Videochat> createState() => _VideochatState();
}

The _VideochatState class will contain the video chat logic:

class _VideochatState extends State<Videochat> {
  final zoom = ZoomVideoSdk();
  final eventListener = ZoomVideoSdkEventListener();
  bool isInSession = false;
  List<StreamSubscription> subscriptions = [];
  List<ZoomVideoSdkUser> users = [];
  bool isMuted = true;
  bool isVideoOn = false;
  bool isLoading = false;

We define the state variables for our video chat component: zoom and eventListener to access the Zoom Video SDK and set up event listeners. subscriptions to store the list of subscriptions, isInSession for session state, users to store the list of users in the session, isMuted for user mic state, isVideoOn for user camera state, and isLoading to show a loading state when joining a session.

Event Listeners

The Zoom Video SDK gives you access to events for various actions, like when a user joins a session, leaves a session, etc. We'll set up event listeners to handle events and manage the app state:

  _handleSessionJoin(data) async {
    if (!mounted) return;
    final mySelf = ZoomVideoSdkUser.fromJson(jsonDecode(data['sessionUser']));
    final remoteUsers = await zoom.session.getRemoteUsers() ?? [];
    final isMutedState = await mySelf.audioStatus?.isMuted() ?? true;
    final isVideoOnState = await mySelf.videoStatus?.isOn() ?? false;
    setState(() {
      isInSession = true;
      isLoading = false;
      isMuted = isMutedState;
      isVideoOn = isVideoOnState;
      users = [mySelf, ...remoteUsers];
    });
  }

The _handleSessionJoin method updates the state when a session is joined. It also updates the user list, adding the self object and the remote users to the list. It also updates the video and audio states based on the user's audio and video status.

  _updateUserList(data) async {
    final mySelf = await zoom.session.getMySelf();
    if (mySelf == null) return;
    final remoteUserList = await zoom.session.getRemoteUsers() ?? [];
    remoteUserList.insert(0, mySelf);
    setState(() {
      users = remoteUserList;
    });
  }

The _updateUserList method updates the user list when remote users join or leave the session.

  _handleVideoChange(data) async {
    if (!mounted) return;
    final mySelf = await zoom.session.getMySelf();
    final videoStatus = await mySelf?.videoStatus?.isOn() ?? false;
    setState(() {
      isVideoOn = videoStatus;
    });
  }

The _handleVideoChange method updates the local user's camera status when it changes.

  _handleAudioChange(data) async {
    if (!mounted) return;
    final mySelf = await zoom.session.getMySelf();
    final audioStatus = await mySelf?.audioStatus?.isMuted() ?? true;
    setState(() {
      isMuted = audioStatus;
    });
  }

The _handleAudioChange method updates the local user's mic status when it changes.

Finally, we'll create a function _setupEventListeners to attach the event listeners and add them to the subscriptions list:

  _setupEventListeners() {
    subscriptions = [
      eventListener.addListener(EventType.onSessionJoin, _handleSessionJoin),
      eventListener.addListener(EventType.onSessionLeave, handleLeaveSession),
      eventListener.addListener(EventType.onUserJoin, _updateUserList),
      eventListener.addListener(EventType.onUserLeave, _updateUserList),
      eventListener.addListener(EventType.onUserVideoStatusChanged, _handleVideoChange),
      eventListener.addListener(EventType.onUserAudioStatusChanged, _handleAudioChange),
    ];
  }

We can use the subscriptions list to cancel the event listeners as part of the cleanup process.

Session Management

Now, let's define methods to start and leave a session:

  Future startSession() async {
    setState(() => isLoading = true);
    try {
      final token = generateJwt(sessionDetails['sessionName'], sessionDetails['roleType']);
      _setupEventListeners();
      await zoom.joinSession(JoinSessionConfig(
        sessionName: sessionDetails['sessionName']!,
        sessionPassword: sessionDetails['sessionPassword']!,
        token: token,
        userName: sessionDetails['displayName']!,
        audioOptions: {"connect": true, "mute": true},
        videoOptions: {"localVideoOn": true},
        sessionIdleTimeoutMins: int.parse(sessionDetails['sessionTimeout']!),
      ));
    } catch (e) {
      debugPrint("Error: $e");
      setState(() => isLoading = false);
    }
  }

The startSession method generates a JWT token and joins a video session using the zoom.joinSession method, passing in the JoinSessionConfig object. We can access the sessionDetails from the config.dart file. Before joining the session, we set up the event listeners by calling the _setupEventListeners method.

We'll define a callback function handleLeaveSession to reset the app state when the user leaves a session and cancel all the event subscriptions:

  handleLeaveSession([data]) {
    setState(() {
      isInSession = false;
      isLoading = false;
      users = [];
    });
    for (var subscription in subscriptions) {
      subscription.cancel();
    }
  }

We'll call this function when the user clicks the leave button.

Step 5: Building the UI

Now, let's build the UI for our video chat screen:

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        backgroundColor: Colors.grey[900],
        body: Stack(
          children: [
            if (!isInSession)
              Center(
                child: ElevatedButton(
                  onPressed: isLoading ? null : startSession,
                  child: Text(isLoading ? 'Connecting...' : 'Start Session'),
                ),
              )
            else
              Stack(
                children: [
                  VideoGrid(users: users),
                  ControlBar(
                    isMuted: isMuted,
                    isVideoOn: isVideoOn,
                    onLeaveSession: handleLeaveSession,
                  ),
                ],
              ),
          ],
        ),
      ),
    );
  }
}

We display a button to start the session when the user is not in a session. When the user is in a session, we display a grid of video tiles using the VideoGrid component passing in the users list. We also display a bar at the bottom of the screen using the ControlBar component passing in the isMuted & isVideoOn states for the mute/unmute audio and video buttons. We also pass in the handleLeaveSession method to the onLeaveSession prop that we'll call when the user clicks the leave button.

screenshot of the app showing a button

Video Grid Component

screenshot of the app showing a grid of user videos

Let's create a component to display the video grid:

class VideoGrid extends StatelessWidget {
  final List<ZoomVideoSdkUser> users;
  const VideoGrid({
    super.key,
    required this.users,
  });
  @override
  Widget build(BuildContext context) {
    if (users.isEmpty) {
      return const Center(child: CircularProgressIndicator());
    }
    return GridView.builder(
      physics: const NeverScrollableScrollPhysics(),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: users.length <= 2 ? 1 : 2,
        crossAxisSpacing: 2,
        mainAxisSpacing: 2,
      ),
      itemCount: users.length,
      itemBuilder: (context, index) => _VideoTile(user: users[index]),
    );
  }
}

The VideoGrid component displays a loading indicator when there are no users, and a grid of video tiles when there are users using the GridView widget adjusting the size based on the number of users.

Video Tile Component

Now, let's create a component to display individual video tiles:

class _VideoTile extends StatelessWidget {
  final ZoomVideoSdkUser user;
  const _VideoTile({required this.user});
  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.black,
      child: SizedBox.expand(
        child: zoom_view.View(
          key: Key(user.userId),
          creationParams: {
            "userId": user.userId,
            "videoAspect": VideoAspect.FullFilled,
            "fullScreen": false,
          },
        ),
      ),
    );
  }
}

The _VideoTile component displays a video tile for a user using the zoom_view.View widget. We pass the user.userId to the creation params to identify the user.

Step 6: Building the control bar

screenshot of the app showing a container with three buttons: mic mute, camera mute and endcall

Let's create the final piece of UI, the ControlBar component. This component will display buttons to mute/unmute the mic and camera along with a button to leave the session:

class ControlBar extends StatelessWidget {
  final bool isMuted;
  final bool isVideoOn;
  final double circleButtonSize = 40.0;
  final zoom = ZoomVideoSdk();
  final VoidCallback onLeaveSession;
  ControlBar({
    super.key,
    required this.isMuted,
    required this.isVideoOn,
    required this.onLeaveSession,
  });

The ControlBar component uses the isMuted and isVideoOn variables to track the mute/unmute state of the mic and camera. We also access the onLeaveSession callback function that we defined in the Videochat component for performing the cleanup after leaving the session.

We'll define the toggleAudio function to toggle the audio status when the user clicks the mute/unmute audio button:

  Future toggleAudio() async {
    final mySelf = await zoom.session.getMySelf();
    if (mySelf?.audioStatus == null) return;
    final isMuted = await mySelf!.audioStatus!.isMuted();
    isMuted ? await zoom.audioHelper.unMuteAudio(mySelf.userId) : await zoom.audioHelper.muteAudio(mySelf.userId);
  }

We use the muteAudio and unMuteAudio methods on the audioHelper object to mute and unmute the audio.

We'll define the toggleVideo function to toggle the video status when the user clicks the mute/unmute video button:

  Future toggleVideo() async {
    final mySelf = await zoom.session.getMySelf();
    if (mySelf?.videoStatus == null) return;
    final isOn = await mySelf!.videoStatus!.isOn();
    isOn ? await zoom.videoHelper.stopVideo() : await zoom.videoHelper.startVideo();
  }

We use the stopVideo and startVideo methods on the videoHelper object to stop and start the video.

Finally, we'll define the leaveSession function to leave the session when the user clicks the leave button:

  Future leaveSession() async {
    await zoom.leaveSession(false);
    onLeaveSession();
  }

We use the leaveSession method to leave the session and call the onLeaveSession function to call the handleLeaveSession method to reset the app state.

We can now build the UI for the ControlBar component:

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.bottomCenter,
      child: FractionallySizedBox(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              onPressed: toggleAudio,
              icon: Icon(
                isMuted ? Icons.mic_off : Icons.mic,
              ),
              iconSize: circleButtonSize,
              tooltip: isMuted ? "Unmute" : "Mute",
              color: Colors.white,
            ),
            IconButton(
              onPressed: toggleVideo,
              iconSize: circleButtonSize,
              icon: Icon(
                isVideoOn ? Icons.videocam : Icons.videocam_off,
                color: Colors.white,
              ),
            ),
            IconButton(
              onPressed: leaveSession,
              iconSize: circleButtonSize,
              icon: const Icon(Icons.call_end, color: Colors.red),
            ),
          ],
        ),
      ),
    );
  }
}

We use IconButton to create the mute/unmute audio and video buttons and the leave button. We pass in the toggleAudio and toggleVideo methods to the onPressed prop and the leaveSession method to the onPressed prop of the leave button.

Step 7: Running the app

You can run the app using the following command:

flutter run

This will start the Flutter development server and open the app on your connected device or emulator.

Note: Emulators may not be able to access the camera, you'll see an empty video view. Please use a physical device to demo the video functionality.

Conclusion

Congratulations! You've successfully integrated video and audio conferencing into your Flutter application using the Zoom Video SDK.

This is just the beginning of what you can do with the Video SDK! You can add other features like screen sharing, chat, cloud recording, and live transcriptions. More information is available under the Add Features section in the Zoom Video SDK for Flutter documentation.