Build a fully customized control panel for your call interface by hiding the default controls and implementing your own UI with call actions. This guide walks you through creating a custom control panel with essential call controls.
Overview
Custom control panels allow you to:
- Match your app’s branding and design language
- Simplify the interface by showing only relevant controls
- Add custom functionality and workflows
- Create unique user experiences
This guide demonstrates building a basic custom control panel with:
- Mute/Unmute audio button
- Pause/Resume video button
- Switch camera button
- End call button
Prerequisites
Step 1: Hide Default Controls
Configure your session settings to hide the default control panel:
final sessionSettings = CometChatCalls.SessionSettingsBuilder()
..hideControlPanel(true);
You can also hide individual buttons while keeping the control panel visible. See SessionSettingsBuilder for all options.
Build a Flutter widget for your custom controls. Instead of XML layouts, Flutter uses a widget tree to compose the UI:
class CustomControlPanel extends StatelessWidget {
final bool isAudioMuted;
final bool isVideoPaused;
final VoidCallback onToggleAudio;
final VoidCallback onToggleVideo;
final VoidCallback onSwitchCamera;
final VoidCallback onEndCall;
const CustomControlPanel({
super.key,
required this.isAudioMuted,
required this.isVideoPaused,
required this.onToggleAudio,
required this.onToggleVideo,
required this.onSwitchCamera,
required this.onEndCall,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.black.withOpacity(0.8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Mute/Unmute Button
_controlButton(
icon: isAudioMuted ? Icons.mic_off : Icons.mic,
color: Colors.grey[800]!,
onPressed: onToggleAudio,
),
const SizedBox(width: 16),
// Pause/Resume Video Button
_controlButton(
icon: isVideoPaused ? Icons.videocam_off : Icons.videocam,
color: Colors.grey[800]!,
onPressed: onToggleVideo,
),
const SizedBox(width: 16),
// Switch Camera Button
_controlButton(
icon: Icons.cameraswitch,
color: Colors.grey[800]!,
onPressed: onSwitchCamera,
),
const SizedBox(width: 16),
// End Call Button
_controlButton(
icon: Icons.call_end,
color: Colors.red,
onPressed: onEndCall,
),
],
),
);
}
Widget _controlButton({
required IconData icon,
required Color color,
required VoidCallback onPressed,
}) {
return GestureDetector(
onTap: onPressed,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white),
),
);
}
}
Step 3: Implement Control Actions
Wire up the button callbacks to call the appropriate CallSession actions:
class CallScreen extends StatefulWidget {
const CallScreen({super.key});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
bool isAudioMuted = false;
bool isVideoPaused = false;
void _toggleAudio() async {
if (isAudioMuted) {
await CallSession.getInstance()?.unMuteAudio();
} else {
await CallSession.getInstance()?.muteAudio();
}
}
void _toggleVideo() async {
if (isVideoPaused) {
await CallSession.getInstance()?.resumeVideo();
} else {
await CallSession.getInstance()?.pauseVideo();
}
}
void _switchCamera() async {
await CallSession.getInstance()?.switchCamera();
}
void _endCall() async {
await CallSession.getInstance()?.leaveSession();
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// Call view fills the screen
// callWidget is the Widget? returned from joinSession
if (callWidget != null) callWidget!,
// Custom control panel at the bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
child: CustomControlPanel(
isAudioMuted: isAudioMuted,
isVideoPaused: isVideoPaused,
onToggleAudio: _toggleAudio,
onToggleVideo: _toggleVideo,
onSwitchCamera: _switchCamera,
onEndCall: _endCall,
),
),
],
),
);
}
}
Step 4: Handle State Updates
Use MediaEventsListener to keep your UI synchronized with the actual call state.
void _setupMediaEventsListener() {
CallSession.getInstance()?.addMediaEventsListener(
MediaEventsListener(
onAudioMuted: () {
setState(() => isAudioMuted = true);
},
onAudioUnMuted: () {
setState(() => isAudioMuted = false);
},
onVideoPaused: () {
setState(() => isVideoPaused = true);
},
onVideoResumed: () {
setState(() => isVideoPaused = false);
},
),
);
}
Flutter listeners are not lifecycle-aware. You must manually remove listeners in dispose() to prevent memory leaks.
Use SessionStatusListener to handle session end events:
void _setupSessionStatusListener() {
CallSession.getInstance()?.addSessionStatusListener(
SessionStatusListener(
onSessionLeft: () {
if (mounted) Navigator.of(context).pop();
},
onConnectionClosed: () {
if (mounted) Navigator.of(context).pop();
},
),
);
}
Complete Example
Here’s the full implementation combining all steps:
import 'package:cometchat_calls_sdk/cometchat_calls_sdk.dart';
import 'package:flutter/material.dart';
class CallScreen extends StatefulWidget {
final String sessionId;
const CallScreen({super.key, required this.sessionId});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
Widget? callWidget;
bool isAudioMuted = false;
bool isVideoPaused = false;
@override
void initState() {
super.initState();
_setupMediaEventsListener();
_setupSessionStatusListener();
_joinCall();
}
void _joinCall() {
final sessionSettings = CometChatCalls.SessionSettingsBuilder()
..setDisplayName("John Doe")
..setType(SessionType.video)
..hideControlPanel(true);
CometChatCalls.joinSession(
sessionId: widget.sessionId,
sessionSettings: sessionSettings.build(),
onSuccess: (Widget? widget) {
setState(() => callWidget = widget);
},
onError: (CometChatCallsException e) {
debugPrint("Failed to join: ${e.message}");
Navigator.of(context).pop();
},
);
}
void _setupMediaEventsListener() {
CallSession.getInstance()?.addMediaEventsListener(
MediaEventsListener(
onAudioMuted: () {
setState(() => isAudioMuted = true);
},
onAudioUnMuted: () {
setState(() => isAudioMuted = false);
},
onVideoPaused: () {
setState(() => isVideoPaused = true);
},
onVideoResumed: () {
setState(() => isVideoPaused = false);
},
),
);
}
void _setupSessionStatusListener() {
CallSession.getInstance()?.addSessionStatusListener(
SessionStatusListener(
onSessionLeft: () {
if (mounted) Navigator.of(context).pop();
},
onConnectionClosed: () {
if (mounted) Navigator.of(context).pop();
},
),
);
}
void _toggleAudio() async {
if (isAudioMuted) {
await CallSession.getInstance()?.unMuteAudio();
} else {
await CallSession.getInstance()?.muteAudio();
}
}
void _toggleVideo() async {
if (isVideoPaused) {
await CallSession.getInstance()?.resumeVideo();
} else {
await CallSession.getInstance()?.pauseVideo();
}
}
void _switchCamera() async {
await CallSession.getInstance()?.switchCamera();
}
void _endCall() async {
await CallSession.getInstance()?.leaveSession();
if (mounted) Navigator.of(context).pop();
}
@override
void dispose() {
CallSession.getInstance()?.removeMediaEventsListener();
CallSession.getInstance()?.removeSessionStatusListener();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
if (callWidget != null) callWidget!,
Positioned(
left: 0,
right: 0,
bottom: 0,
child: CustomControlPanel(
isAudioMuted: isAudioMuted,
isVideoPaused: isVideoPaused,
onToggleAudio: _toggleAudio,
onToggleVideo: _toggleVideo,
onSwitchCamera: _switchCamera,
onEndCall: _endCall,
),
),
],
),
);
}
}