Skip to main content
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.

Step 2: Create Custom Widget Layout

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,
            ),
          ),
        ],
      ),
    );
  }
}