Skip to main content

Build Video Call App with Flutter and ZEGOCLOUD SDK

· 13 min read

thumbnail

Hello everyone, this time we will build a video call application using the Flutter framework with the video call SDK from ZEGOCLOUD. ZEGOCLOUD is a platform that provides global communication services such as Video Call, Live-Streaming, Chat API, and many more.

ZEGOCLOUD offers a ui kit to use their products. including to use their video call product, which you can check here. However, this time we will implement the video call SDK version or the full custom version.

First of all, you must have a zegocloud account first. The good news is, zegocloud offers free credit for new users. By registering a new account, you can get 10,000 minutes for free.

Prepare

After register, login to your account then open zegocloud dashboard.

  1. Create your zegocloud project.

alt

  1. Select Voice & Video Call.

alt

  1. Input project name, e.g., "VCall_App".

alt

  1. Start with Custom SDKs.

alt

  1. Open Detail Project ZEGOCLOUD in Project Information Tab, you will find AppID and AppSign. Kepp this secret and don't share to public.

alt

After create ZEGOCLOUD project for Video Call. Move on to creating a Flutter App.

My Environment
  • Flutter: 3.29.0
  • IDE: Visual Studio Code
  • OS: Microsoft Windows 11
  • Android Studio: 2024.2
  1. Create Flutter project.

alt

alt

alt

alt

  1. Run this command to add zego engine package
flutter pub add zego_express_engine
  1. Create new Class to store ZEGOCLOUD Secret keys.

alt

lib/config/zegocloud_keys.dart
class ZegocloudKeys {
static const appId = 0;
static const appSign = '';
}
  1. Check here to setup Android Gradle which are using Android Studio latest version and Flutter latest version.

Configure Project

Android

Add these permission to Android manifest inside manifest tag.

android/app/src/main/AndroidManifest.xml
<!-- Permissions required by the SDK -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

<!-- Permissions required by the Demo App -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<uses-feature android:glEsVersion="0x00020000" android:required="true" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

alt

to prevent code obfuscation for Android devices, you can setup android guard then implement to gradle.

android/app/proguard-rules.pro
-keep class **.zego.** { *; }
# to shrink your code with R8 add below
-dontwarn com.itgsa.opensdk.mediaunit.KaraokeMediaHelper
android/app/build.gradle.kts
...
android {
...

buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
}

...

iOS

Add permission to Info.plist inside dict tag.

ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>We require camera access to connect</string>
<key>NSMicrophoneUsageDescription</key>
<string>We require microphone access to connect</string>

alt

Login Page

First we create an entry point page to get user id and username. You can also add room id input from the beginning. For that, we will using login template from Flutter Delux.

Open auth sample page template, copy & paste to login_page.dart and modify like below.

lib/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:d_input/d_input.dart';
import 'package:gap/gap.dart';

class LoginPage extends StatefulWidget {
const LoginPage({super.key});


State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
final _userIdController = TextEditingController();
final _userNameController = TextEditingController();
final _roomIdController = TextEditingController();

void _executeLogin() {
final userId = _userIdController.text;
if (userId == '') return _showInvalidMessage('User Id must be filled');
final userName = _userNameController.text;
if (userName == '') return _showInvalidMessage('User Name must be filled');
final roomId = _roomIdController.text;
if (roomId == '') return _showInvalidMessage('Room Id must be filled');

Navigator.pushNamed(
context,
'/call',
arguments: {'userId': userId, 'userName': userName, 'roomId': roomId},
);
}


void dispose() {
_userIdController.dispose();
_userNameController.dispose();
_roomIdController.dispose();
super.dispose();
}


Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Padding(
padding: const EdgeInsets.all(30),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(radius: 80, child: Icon(Icons.call, size: 80)),
Gap(30),
Text(
'VCall App',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 30,
color: Theme.of(context).primaryColor,
),
),
Gap(70),
DInputMix(controller: _userIdController, hint: 'User Id'),
Gap(20),
DInputMix(
controller: _userNameController,
hint: 'User Name',
),
Gap(20),
DInputMix(controller: _roomIdController, hint: 'Room Id'),
Gap(20),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
onPressed: _executeLogin,
child: Text('LOGIN'),
),
),
],
),
),
),
);
},
),
);
}

void _showInvalidMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message, style: TextStyle(color: Colors.white)),
duration: Duration(seconds: 2),
backgroundColor: Colors.red,
),
);
}
}

Run flutter pub add d_input gap to add package required from template. Inside Material App, setup launch route to Login page.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_zegocloud_vcall/pages/login_page.dart';

void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MainApp());
}

class MainApp extends StatelessWidget {
const MainApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
routes: {'/': (context) => LoginPage()},
);
}
}

Login Page will be like this:

Create Engine

Create function to initialize zegocloud engine, put inside main.dart and call inside main function.

lib/main.dart
import 'package:zego_express_engine/zego_express_engine.dart';
import 'package:flutter_zegocloud_vcall/config/zegocloud_keys.dart';

Future<void> createEngine() async {
await ZegoExpressEngine.createEngineWithProfile(
ZegoEngineProfile(
ZegocloudKeys.appId,
ZegoScenario.Default,
appSign: ZegocloudKeys.appSign,
),
);
}

void main() {
WidgetsFlutterBinding.ensureInitialized();
createEngine();
runApp(const MainApp());
}

Call Page

Arguments

Create Call Page class with Statefull Widget and create 3 argument to receive data were sent from Login Page.

lib/pages/call_page.dart
...

const CallPage({
super.key,
required this.userId,
required this.userName,
required this.roomId,
});

final String userId;
final String userName;
final String roomId;

...

Register call page to route in MaterialApp.

lib/main.dart
...
return MaterialApp(
debugShowCheckedModeBanner: false,
routes: {
'/': (context) => LoginPage(),
'/call': (context) {
final data = ModalRoute.settingsOf(context)?.arguments as Map?;
if (data == null) {
return Scaffold(appBar: AppBar(title: Text('Invalid Argument')));
}
return CallPage(
userId: data['userId'],
userName: data['userName'],
roomId: data['roomId'],
);
},
},
);
...

User View

Create variable to handle user local and user remote.

Widget? localView;
int? localViewID;
Widget? remoteView;
int? remoteViewID;

alt

UI

alt

Build method inside call page will be like this:

lib/pages/call_page.dart
...

Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Call Page")),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: localView ?? ColoredBox(color: Colors.black)),
Positioned(
top: MediaQuery.sizeOf(context).height / 20,
right: MediaQuery.sizeOf(context).width / 20,
width: MediaQuery.sizeOf(context).width / 4,
child: AspectRatio(
aspectRatio: 9 / 16,
child:
remoteView ??
Container(
color: Colors.black,
child: Icon(Icons.not_interested, color: Colors.red),
),
),
),
Positioned(
bottom: MediaQuery.of(context).size.height / 20,
left: 0,
right: 0,
child: Center(
child: IconButton.filled(
style: IconButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.call_end, size: 32),
),
),
),
],
),
);
}
...

Call Process

  
void initState() {
try {
startListen();
loginRoom();
} catch (e) {
log(e.toString());
}
super.initState();
}


void dispose() {
try {
stopListen();
logoutRoom();
} catch (e) {
log(e.toString());
}
super.dispose();
}

Functions

For User Local

loginRoom
Future<void> loginRoom() async {
final user = ZegoUser(widget.userId, widget.userName);
ZegoRoomConfig roomConfig =
ZegoRoomConfig.defaultConfig()..isUserStatusNotify = true;

final loginRoomResult = await ZegoExpressEngine.instance.loginRoom(
widget.roomId,
user,
config: roomConfig,
);
log(
'loginRoom: errorCode:${loginRoomResult.errorCode}, extendedData:${loginRoomResult.extendedData}',
);

if (loginRoomResult.errorCode != 0) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Call Failed')));
return;
}

startPreview();
publishStream();
}
logoutRoom
Future<void> logoutRoom() async {
stopPreview();
unpublishStream();
await ZegoExpressEngine.instance.logoutRoom(widget.roomId);
}
startPreview
Future<void> startPreview() async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
localViewID = viewID;
ZegoExpressEngine.instance.startPreview(
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});

localView = canvasViewWidget;
setState(() {});
}
stopPreview
Future<void> stopPreview() async {
ZegoExpressEngine.instance.stopPreview();

if (localViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(localViewID!);

if (!mounted) return;
localView = null;
localViewID = null;
setState(() {});
}
publishStream
Future<void> publishStream() async {
final streamID = '${widget.roomId}_${widget.userId}_call';
await ZegoExpressEngine.instance.startPublishingStream(streamID);
}
unpublishStream
Future<void> unpublishStream() async {
return ZegoExpressEngine.instance.stopPublishingStream();
}

For User Remote

startListen
void startListen() {
ZegoExpressEngine.onRoomUserUpdate = (
roomID,
updateType,
List<ZegoUser> users,
) {
log(
'onRoomUserUpdate: roomID: $roomID, updateType: ${updateType.name}, userList: ${users.map((e) => e.userID)}',
);
};

ZegoExpressEngine.onRoomStreamUpdate = (
roomID,
updateType,
List<ZegoStream> streams,
extendedData,
) {
log(
'onRoomStreamUpdate: roomID: $roomID, updateType: ${updateType.name}, streams: ${streams.map((e) => e.user.userName)}, extendedData: $extendedData',
);
if (updateType == ZegoUpdateType.Add) {
for (final stream in streams) {
startStream(stream.streamID);
}
} else {
for (final stream in streams) {
stopStream(stream.streamID);
}
}
};

ZegoExpressEngine.onRoomStateUpdate = (
roomID,
state,
errorCode,
extendedData,
) {
log(
'onRoomStateUpdate: roomID: $roomID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};

ZegoExpressEngine.onPublisherStateUpdate = (
streamID,
state,
errorCode,
extendedData,
) {
log(
'onPublisherStateUpdate: streamID: $streamID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};
}
stopListen
void stopListen() {
ZegoExpressEngine.onRoomUserUpdate = null;
ZegoExpressEngine.onRoomStreamUpdate = null;
ZegoExpressEngine.onRoomStateUpdate = null;
ZegoExpressEngine.onPublisherStateUpdate = null;
}
startStream
Future<void> startStream(String streamID) async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
remoteViewID = viewID;
ZegoExpressEngine.instance.startPlayingStream(
streamID,
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});
remoteView = canvasViewWidget;
setState(() {});
}
stopStream
Future<void> stopStream(String streamID) async {
await ZegoExpressEngine.instance.stopPlayingStream(streamID);

if (remoteViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(remoteViewID!);

if (!mounted) return;
remoteView = null;
remoteViewID = null;
setState(() {});
}
Call Page Full Code

Here for call page full code.

lib/pages/call_page.dart
import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:zego_express_engine/zego_express_engine.dart';

class CallPage extends StatefulWidget {
const CallPage({
super.key,
required this.userId,
required this.userName,
required this.roomId,
});

final String userId;
final String userName;
final String roomId;


State<CallPage> createState() => _CallPageState();
}

class _CallPageState extends State<CallPage> {
Widget? localView;
int? localViewID;
Widget? remoteView;
int? remoteViewID;


void initState() {
try {
startListen();
loginRoom();
} catch (e) {
log(e.toString());
}
super.initState();
}


void dispose() {
try {
stopListen();
logoutRoom();
} catch (e) {
log(e.toString());
}
super.dispose();
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Call Page")),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: localView ?? ColoredBox(color: Colors.black)),
Positioned(
top: MediaQuery.sizeOf(context).height / 20,
right: MediaQuery.sizeOf(context).width / 20,
width: MediaQuery.sizeOf(context).width / 4,
child: AspectRatio(
aspectRatio: 9 / 16,
child:
remoteView ??
Container(
color: Colors.black,
child: Icon(Icons.not_interested, color: Colors.red),
),
),
),
Positioned(
bottom: MediaQuery.of(context).size.height / 20,
left: 0,
right: 0,
child: Center(
child: IconButton.filled(
style: IconButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.call_end, size: 32),
),
),
),
],
),
);
}

Future<void> loginRoom() async {
final user = ZegoUser(widget.userId, widget.userName);
ZegoRoomConfig roomConfig =
ZegoRoomConfig.defaultConfig()..isUserStatusNotify = true;

final loginRoomResult = await ZegoExpressEngine.instance.loginRoom(
widget.roomId,
user,
config: roomConfig,
);
log(
'loginRoom: errorCode:${loginRoomResult.errorCode}, extendedData:${loginRoomResult.extendedData}',
);

if (loginRoomResult.errorCode != 0) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Call Failed')));
return;
}

startPreview();
publishStream();
}

Future<void> logoutRoom() async {
stopPreview();
unpublishStream();
await ZegoExpressEngine.instance.logoutRoom(widget.roomId);
}

Future<void> startPreview() async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
localViewID = viewID;
ZegoExpressEngine.instance.startPreview(
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});

localView = canvasViewWidget;
setState(() {});
}

Future<void> stopPreview() async {
ZegoExpressEngine.instance.stopPreview();

if (localViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(localViewID!);

if (!mounted) return;
localView = null;
localViewID = null;
setState(() {});
}

Future<void> publishStream() async {
final streamID = '${widget.roomId}_${widget.userId}_call';
await ZegoExpressEngine.instance.startPublishingStream(streamID);
}

Future<void> unpublishStream() async {
return ZegoExpressEngine.instance.stopPublishingStream();
}

void startListen() {
ZegoExpressEngine.onRoomUserUpdate = (
roomID,
updateType,
List<ZegoUser> users,
) {
log(
'onRoomUserUpdate: roomID: $roomID, updateType: ${updateType.name}, userList: ${users.map((e) => e.userID)}',
);
};

ZegoExpressEngine.onRoomStreamUpdate = (
roomID,
updateType,
List<ZegoStream> streams,
extendedData,
) {
log(
'onRoomStreamUpdate: roomID: $roomID, updateType: ${updateType.name}, streams: ${streams.map((e) => e.user.userName)}, extendedData: $extendedData',
);
if (updateType == ZegoUpdateType.Add) {
for (final stream in streams) {
startStream(stream.streamID);
}
} else {
for (final stream in streams) {
stopStream(stream.streamID);
}
}
};

ZegoExpressEngine.onRoomStateUpdate = (
roomID,
state,
errorCode,
extendedData,
) {
log(
'onRoomStateUpdate: roomID: $roomID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};

ZegoExpressEngine.onPublisherStateUpdate = (
streamID,
state,
errorCode,
extendedData,
) {
log(
'onPublisherStateUpdate: streamID: $streamID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};
}

void stopListen() {
ZegoExpressEngine.onRoomUserUpdate = null;
ZegoExpressEngine.onRoomStreamUpdate = null;
ZegoExpressEngine.onRoomStateUpdate = null;
ZegoExpressEngine.onPublisherStateUpdate = null;
}

Future<void> startStream(String streamID) async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
remoteViewID = viewID;
ZegoExpressEngine.instance.startPlayingStream(
streamID,
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});
remoteView = canvasViewWidget;
setState(() {});
}

Future<void> stopStream(String streamID) async {
await ZegoExpressEngine.instance.stopPlayingStream(streamID);

if (remoteViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(remoteViewID!);

if (!mounted) return;
remoteView = null;
remoteViewID = null;
setState(() {});
}
}

Permission

On some Android version, permission not automatically allowed and must be set request permission manually. So we must implement code for permission using permission_handler package. Request permission will be execute when open Call Page.

lib/utils/permission.dart
import 'dart:developer';
import 'package:permission_handler/permission_handler.dart';

Future<void> requestPermission() async {
try {
PermissionStatus microphoneStatus = await Permission.microphone.request();
if (microphoneStatus != PermissionStatus.granted) {
log('Error: Microphone permission not granted!!!');
return;
}
} on Exception catch (error) {
log("[ERROR], request microphone permission exception, $error");
}

try {
PermissionStatus cameraStatus = await Permission.camera.request();
if (cameraStatus != PermissionStatus.granted) {
log('Error: Camera permission not granted!!!');
return;
}
} on Exception catch (error) {
log("[ERROR], request camera permission exception, $error");
}
}
lib/main.dart
import 'package:flutter_zegocloud_vcall/utils/permission.dart';
...
routes: {
'/': (context) => LoginPage(),
'/call': (context) {
final data = ModalRoute.settingsOf(context)?.arguments as Map?;
if (data == null) {
return Scaffold(appBar: AppBar(title: Text('Invalid Argument')));
}
requestPermission();
return CallPage(
userId: data['userId'],
userName: data['userName'],
roomId: data['roomId'],
);
},
},
...

Flow

Sequence
User open App
Create Engine
Redirect Login Page
Go to Call PagerequestPermission
startListen
loginRoom

startStream
startPreview & publishStream
Waiting for User Remote Connected
Call Activity
End Call Button or Back Button
stopListen
logoutRoom

stopStream
stopPreview & unpublishStream
Back to Login Page

Final Result

Here are some views of the final results. Test using OS Android 10 and 15.

Request PermissionUser Remote Not ConnectedUser 1 (Android 15)User 2 (Android 10)
altaltaltalt

Next Step

Make more customize and feature for example you can add call invitation.