Nathan McDonald
Nathan McDonald

Reputation: 1

Agora Engine Flutter

I have integrated Agora RTC Engine FLutter SDK into my app, and it's working fine on Desktop through the web app. But when I access it through Chrome or Safari on mobile, the video from the video doesn't show on the phone. The camera is permitted.

Whilst the camera is permitted on the iPhone, and the video doesn't show on the iPhone, i can see the video from the iPhone camera as the remote user when connected via the web on a MacBook.

This is really bizarre and has had me baffled for quite a bit.

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:math';
import 'package:agora_rtc_engine/rtc_engine.dart';
import 'package:agora_rtc_engine/rtc_local_view.dart' as rtc_local_view;
import 'package:agora_rtc_engine/rtc_remote_view.dart' as rtc_remote_view;
import 'package:flutter_dotenv/flutter_dotenv.dart';

class VideoChatWidget extends StatefulWidget {
  const VideoChatWidget({
    Key? key,
    this.width,
    this.height,
    required this.token,
    required this.channelName,
    required this.appId,
  }) : super(key: key);

  final double? width;
  final double? height;
  final String token;
  final String channelName;
  final String appId;

  @override
  State<VideoChatWidget> createState() => _VideoChatWidgetState();
}

// SIMPLIFIED ABOVE DONE

class _VideoChatWidgetState extends State<VideoChatWidget> {
  bool _isMicEnabled = false;
  bool _isCameraEnabled = false;
  bool _isJoining = false;

  @override
  void initState() {
    _getPermissions();
    super.initState();
  }

// SIMPLIFIED ABOVE DONE

  Future<void> _getMicPermissions() async {
    if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
      final micPermission = await Permission.microphone.request();
      if (micPermission == PermissionStatus.granted) {
        setState(() => _isMicEnabled = true);
      }
    } else {
      setState(() => _isMicEnabled = !_isMicEnabled);
    }
  }

  Future<void> _getCameraPermissions() async {
    if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
      final cameraPermission = await Permission.camera.request();
      if (cameraPermission == PermissionStatus.granted) {
        setState(() => _isCameraEnabled = true);
      }
    } else {
      setState(() => _isCameraEnabled = !_isCameraEnabled);
    }
  }

  Future<void> _getPermissions() async {
    await _getMicPermissions();
    await _getCameraPermissions();
  }

  @override
  Widget build(BuildContext context) {
    return Dialog(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(20),
      ),
      child: Container(
        width: MediaQuery.of(context).size.width * 0.80,
        height: 700,
        padding: EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Text(
              'Joining Call',
              style: TextStyle(
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
            Text(
              'You are about to join a video call. Please set your mic and camera preferences.',
              textAlign: TextAlign.center,
              style: TextStyle(fontSize: 16),
            ),
            buildOption(
                'Mic', _isMicEnabled, Icons.mic_rounded, _getMicPermissions),
            SizedBox(width: 20), // Adds space between the buttons
            buildOption('Camera', _isCameraEnabled, Icons.videocam_rounded,
                _getCameraPermissions),
            ElevatedButton(
              onPressed: _isJoining ? null : _joinCall,
              style: ElevatedButton.styleFrom(
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(5.0),
                ),
              ),
              child: _isJoining
                  ? CircularProgressIndicator()
                  : Text(
                      'Join',
                      style: TextStyle(fontSize: 20),
                    ),
            ),
          ],
        ),
      ),
    );
  }

  // SIMPLIFIED ABOVE DONE

  Widget buildOption(
      String label, bool isEnabled, IconData icon, VoidCallback onTap) {
    return InkWell(
      borderRadius: BorderRadius.circular(32),
      onTap: onTap,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          CircleAvatar(
            backgroundColor: isEnabled
                ? const Color.fromARGB(255, 49, 229, 142)
                : Colors.redAccent,
            radius: 32.0,
            child: Icon(
              icon,
              size: 32,
              color: Colors.white,
            ),
          ),
          SizedBox(height: 8),
          Text(
            '$label: ${isEnabled ? 'On' : 'Off'}',
            style: TextStyle(color: Colors.black),
          ),
        ],
      ),
    );
  }

  Future<void> _joinCall() async {
    setState(() => _isJoining = true);
    // Add your logic here
    setState(() => _isJoining = false);
    if (context.mounted) {
      Navigator.of(context).pop();
      await Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => VideoCallPage(
            appId: widget.appId,
            token: widget.token,
            channelName: widget.channelName,
            isMicEnabled: _isMicEnabled,
            isVideoEnabled: _isCameraEnabled,
          ),
        ),
      );
    }
  }
}

//SIMPLIFIED ABOVE DONE

class VideoCallPage extends StatefulWidget {
  const VideoCallPage({
    super.key,
    required this.appId,
    required this.token,
    required this.channelName,
    required this.isMicEnabled,
    required this.isVideoEnabled,
  });

  final String appId;
  final String token;
  final String channelName;
  final bool isMicEnabled;
  final bool isVideoEnabled;

  @override
  State<VideoCallPage> createState() => _VideoCallPageState();
}

class _VideoCallPageState extends State<VideoCallPage> {
  late final RtcEngine _agoraEngine;
  late final _users = <AgoraUser>{};
  late double _viewAspectRatio;

  int? _currentUid;
  bool _isMicEnabled = false;
  bool _isVideoEnabled = false;

  Future<void> _initAgoraRtcEngine() async {
    _agoraEngine = await RtcEngine.create(widget.appId);
    VideoEncoderConfiguration configuration = VideoEncoderConfiguration();
    configuration.orientationMode = VideoOutputOrientationMode.Adaptative;
    await _agoraEngine.setVideoEncoderConfiguration(configuration);
    await _agoraEngine.enableAudio();
    await _agoraEngine.enableVideo();
    await _agoraEngine.setChannelProfile(ChannelProfile.LiveBroadcasting);
    await _agoraEngine.setClientRole(ClientRole.Broadcaster);
    await _agoraEngine.muteLocalAudioStream(!widget.isMicEnabled);
    await _agoraEngine.muteLocalVideoStream(!widget.isVideoEnabled);
  }

  void _addAgoraEventHandlers() => _agoraEngine.setEventHandler(
        RtcEngineEventHandler(
          error: (code) {
            final info = 'LOG::onError: $code';
            debugPrint(info);
          },
          joinChannelSuccess: (channel, uid, elapsed) {
            final info = 'LOG::onJoinChannel: $channel, uid: $uid';
            debugPrint(info);
            setState(() {
              _currentUid = uid;
              _users.add(
                AgoraUser(
                  uid: uid,
                  isAudioEnabled: _isMicEnabled,
                  isVideoEnabled: _isVideoEnabled,
                  view: const rtc_local_view.SurfaceView(),
                ),
              );
            });
          },
          firstLocalAudioFrame: (elapsed) {
            final info = 'LOG::firstLocalAudio: $elapsed';
            debugPrint(info);
            for (AgoraUser user in _users) {
              if (user.uid == _currentUid) {
                setState(() => user.isAudioEnabled = _isMicEnabled);
              }
            }
          },
          firstLocalVideoFrame: (width, height, elapsed) {
            debugPrint('LOG::firstLocalVideo');
            for (AgoraUser user in _users) {
              if (user.uid == _currentUid) {
                setState(
                  () => user
                    ..isVideoEnabled = _isVideoEnabled
                    ..view = const rtc_local_view.SurfaceView(
                      renderMode: VideoRenderMode.Hidden,
                    ),
                );
              }
            }
          },
          leaveChannel: (stats) {
            debugPrint('LOG::onLeaveChannel');
            setState(() => _users.clear());
          },
          userJoined: (uid, elapsed) {
            final info = 'LOG::userJoined: $uid';
            debugPrint(info);
            setState(
              () => _users.add(
                AgoraUser(
                  uid: uid,
                  view: rtc_remote_view.SurfaceView(
                    channelId: widget.channelName,
                    uid: uid,
                  ),
                ),
              ),
            );
          },
          userOffline: (uid, elapsed) {
            final info = 'LOG::userOffline: $uid';
            debugPrint(info);
            AgoraUser? userToRemove;
            for (AgoraUser user in _users) {
              if (user.uid == uid) {
                userToRemove = user;
              }
            }
            setState(() => _users.remove(userToRemove));
          },
          firstRemoteAudioFrame: (uid, elapsed) {
            final info = 'LOG::firstRemoteAudio: $uid';
            debugPrint(info);
            for (AgoraUser user in _users) {
              if (user.uid == uid) {
                setState(() => user.isAudioEnabled = true);
              }
            }
          },
          firstRemoteVideoFrame: (uid, width, height, elapsed) {
            final info = 'LOG::firstRemoteVideo: $uid ${width}x $height';
            debugPrint(info);
            for (AgoraUser user in _users) {
              if (user.uid == uid) {
                setState(
                  () => user
                    ..isVideoEnabled = true
                    ..view = rtc_remote_view.SurfaceView(
                      channelId: widget.channelName,
                      uid: uid,
                    ),
                );
              }
            }
          },
          remoteVideoStateChanged: (uid, state, reason, elapsed) {
            final info = 'LOG::remoteVideoStateChanged: $uid $state $reason';
            debugPrint(info);
            for (AgoraUser user in _users) {
              if (user.uid == uid) {
                setState(() =>
                    user.isVideoEnabled = state != VideoRemoteState.Stopped);
              }
            }
          },
          remoteAudioStateChanged: (uid, state, reason, elapsed) {
            final info = 'LOG::remoteAudioStateChanged: $uid $state $reason';
            debugPrint(info);
            for (AgoraUser user in _users) {
              if (user.uid == uid) {
                setState(() =>
                    user.isAudioEnabled = state != AudioRemoteState.Stopped);
              }
            }
          },
        ),
      );

  Future<void> _initialize() async {
    // Set aspect ratio for video according to platform
    if (kIsWeb) {
      _viewAspectRatio = 3 / 2;
    } else if (Platform.isAndroid || Platform.isIOS) {
      _viewAspectRatio = 2 / 3;
    } else {
      _viewAspectRatio = 3 / 2;
    }
    // Initialize microphone and camera
    setState(() {
      _isMicEnabled = widget.isMicEnabled;
      _isVideoEnabled = widget.isVideoEnabled;
    });
    await _initAgoraRtcEngine();
    _addAgoraEventHandlers();
    final options = ChannelMediaOptions(
      publishLocalAudio: _isMicEnabled,
      publishLocalVideo: _isVideoEnabled,
    );
    // Join the channel
    await _agoraEngine.joinChannel(
      widget.token,
      widget.channelName,
      null, // optionalInfo (unused)
      0, // User ID
      options,
    );
  }

  @override
  void initState() {
    _initialize();
    super.initState();
  }

  void _onToggleAudio() {
    setState(() {
      _isMicEnabled = !_isMicEnabled;
      for (AgoraUser user in _users) {
        if (user.uid == _currentUid) {
          user.isAudioEnabled = _isMicEnabled;
        }
      }
    });
    _agoraEngine.muteLocalAudioStream(!_isMicEnabled);
  }

  void _onToggleCamera() {
    setState(() {
      _isVideoEnabled = !_isVideoEnabled;
      for (AgoraUser user in _users) {
        if (user.uid == _currentUid) {
          setState(() => user.isVideoEnabled = _isVideoEnabled);
        }
      }
    });
    _agoraEngine.muteLocalVideoStream(!_isVideoEnabled);
  }

  void _onSwitchCamera() => _agoraEngine.switchCamera();

  Future<void> _onCallEnd(BuildContext context) async {
    await _agoraEngine.leaveChannel();
    if (context.mounted) {
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    // Placeholder build method, replace with your actual UI
    return Scaffold(
      appBar: AppBar(
        title: Text('Video Call Page'),
      ),
      body: Stack(
        children: [
          OrientationBuilder(
            builder: (context, orientation) {
              final isPortrait = orientation == Orientation.portrait;
              if (_users.isEmpty) {
                return const SizedBox();
              }
              WidgetsBinding.instance.addPostFrameCallback(
                (_) => setState(
                    () => _viewAspectRatio = isPortrait ? 2 / 3 : 3 / 2),
              );
              final layoutViews = _createLayout(_users.length);
              return AgoraVideoLayout(
                users: _users,
                views: layoutViews,
                viewAspectRatio: _viewAspectRatio,
              );
            },
          ),
          CallActionsRow(
            isMicEnabled: _isMicEnabled,
            isVideoEnabled: _isVideoEnabled,
            onCallEnd: () => _onCallEnd(context),
            onToggleAudio: _onToggleAudio,
            onToggleCamera: _onToggleCamera,
            onSwitchCamera: _onSwitchCamera,
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _users.clear();
    _disposeAgora();
    super.dispose();
  }

  Future<void> _disposeAgora() async {
    await _agoraEngine.leaveChannel();
    await _agoraEngine.destroy();
  }
}

class CallActionButton extends StatelessWidget {
  const CallActionButton({
    super.key,
    this.onTap,
    required this.icon,
    this.callEnd = false,
    this.isEnabled = true,
  });

  final Function()? onTap;
  final IconData icon;
  final bool callEnd;
  final bool isEnabled;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      borderRadius: BorderRadius.circular(32),
      onTap: onTap,
      child: CircleAvatar(
        backgroundColor: callEnd
            ? Colors.redAccent
            : isEnabled
                ? Colors.grey.shade800
                : Colors.white,
        radius: callEnd ? 28 : 24,
        child: Icon(
          icon,
          size: callEnd ? 26 : 22,
          color: callEnd
              ? Colors.white
              : isEnabled
                  ? Colors.white
                  : Colors.grey.shade600,
        ),
      ),
    );
  }
}

class AgoraVideoView extends StatelessWidget {
  const AgoraVideoView({
    super.key,
    required double viewAspectRatio,
    required AgoraUser user,
  })  : _viewAspectRatio = viewAspectRatio,
        _user = user;

  final double _viewAspectRatio;
  final AgoraUser _user;

  @override
  Widget build(BuildContext context) {
    return Flexible(
      child: Padding(
        padding: const EdgeInsets.all(2.0),
        child: AspectRatio(
          aspectRatio: _viewAspectRatio,
          child: Container(
            decoration: BoxDecoration(
              color: Colors.grey.shade900,
              borderRadius: BorderRadius.circular(8.0),
              border: Border.all(
                color: _user.isAudioEnabled ?? false ? Colors.blue : Colors.red,
                width: 2.0,
              ),
            ),
            child: Stack(
              children: [
                Center(
                  child: CircleAvatar(
                    backgroundColor: Colors.grey.shade800,
                    maxRadius: 18,
                    child: Icon(
                      Icons.person,
                      color: Colors.grey.shade600,
                      size: 24.0,
                    ),
                  ),
                ),
                if (_user.isVideoEnabled ?? false)
                  ClipRRect(
                    borderRadius: BorderRadius.circular(8 - 2),
                    child: _user.view,
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class AgoraVideoLayout extends StatelessWidget {
  const AgoraVideoLayout({
    super.key,
    required Set<AgoraUser> users,
    required List<int> views,
    required double viewAspectRatio,
  })  : _users = users,
        _views = views,
        _viewAspectRatio = viewAspectRatio;

  final Set<AgoraUser> _users;
  final List<int> _views;
  final double _viewAspectRatio;

  @override
  Widget build(BuildContext context) {
    int totalCount = _views.reduce((value, element) => value + element);
    int rows = _views.length;
    int columns = _views.reduce(max);

    List<Widget> rowsList = [];
    for (int i = 0; i < rows; i++) {
      List<Widget> rowChildren = [];
      for (int j = 0; j < columns; j++) {
        int index = i * columns + j;
        if (index < totalCount) {
          rowChildren.add(
            AgoraVideoView(
              user: _users.elementAt(index),
              viewAspectRatio: _viewAspectRatio,
            ),
          );
        } else {
          rowChildren.add(
            const SizedBox.shrink(),
          );
        }
      }
      rowsList.add(
        Flexible(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: rowChildren,
          ),
        ),
      );
    }
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: rowsList,
    );
  }
}

List<int> _createLayout(int n) {
  int rows = (sqrt(n).ceil());
  int columns = (n / rows).ceil();

  List<int> layout = List<int>.filled(rows, columns);
  int remainingScreens = rows * columns - n;

  for (int i = 0; i < remainingScreens; i++) {
    layout[layout.length - 1 - i] -= 1;
  }

  return layout;
}

class CallActionsRow extends StatelessWidget {
  const CallActionsRow({
    super.key,
    required this.isMicEnabled,
    required this.isVideoEnabled,
    required this.onCallEnd,
    required this.onToggleAudio,
    required this.onToggleCamera,
    required this.onSwitchCamera,
  });

  final bool isMicEnabled;
  final bool isVideoEnabled;
  final Function()? onCallEnd;
  final Function()? onToggleAudio;
  final Function()? onToggleCamera;
  final Function()? onSwitchCamera;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 400,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          CallActionButton(
            callEnd: true,
            icon: Icons.call_end,
            onTap: onCallEnd,
          ),
          CallActionButton(
            icon: isMicEnabled ? Icons.mic : Icons.mic_off,
            isEnabled: isMicEnabled,
            onTap: onToggleAudio,
          ),
          CallActionButton(
            icon: isVideoEnabled
                ? Icons.videocam_rounded
                : Icons.videocam_off_rounded,
            isEnabled: isVideoEnabled,
            onTap: onToggleCamera,
          ),
          CallActionButton(
            icon: Icons.cameraswitch_rounded,
            onTap: onSwitchCamera,
          ),
        ],
      ),
    );
  }
}

class AgoraUser {
  final int uid;
  String? name;
  bool? isAudioEnabled;
  bool? isVideoEnabled;
  Widget? view;

  AgoraUser({
    required this.uid,
    this.name,
    this.isAudioEnabled,
    this.isVideoEnabled,
    this.view,
  });
}

Upvotes: 0

Views: 128

Answers (0)

Related Questions