Reputation: 1
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