Re-joining a call not working for videoroom plugin
Closed this issue · 15 comments
My application is working fine, but if a participant re-joins the same room then its not working as expected. If two participants are in the call and a third one joins the other two will subscribe to the new stream and everything is fine. However, if one participant leaves the call on the janus log in my server this message is displayed: "Publisher 239 not found, not unsubscribing" (239 is a unique ID for each user I provide from the client code). Then if the same user tries to join the same call again the other participants in the call will not be notified to subscribe again to this user, but the new user will subscribe to the other participants' streams without problems. Therefore what happens is that the participant that rejoins the call is able to see and hear the other participants but the existing participants are not aware of him.
If you are calling hangup your plugin handle will be destroyed you will need to reattach a new plugin which will have brand new peer connection
Also each publisher should have unique integer id unless string id support is explicitly enabled, hence you are seeing the error publisher x not found i don't know your code but if you follow along the official example you will be able to isolate your problem
Once a participant exited the call, when he joins back again the whole process is started again where the new plugin is attached again. Also the unique id im using currently is already an integer. By calling hangup do you mean using this function: await videoPlugin?.hangup() in the callEnd() function? But then since I go through the same flow again when rejoining the plugin will be reattached to it should not be a problem
Can you verify whether it is happening with janus official servers? Also verify is it happening on specific device only?
So far I have only tested on android devices, (Samsung, Realme and Oppo). All of them have the same problem. Ok noted I will check with janus official servers
Hi, I tried to test with the example from github. I get the same problem. If a user joins, then exits and then try to rejoin again then the other participants will not receive his stream.
Okay can you share a reproducible code
It's hard to read, please share as small GitHub repo showcasing just janus specific logic
Due to certain constraints I cannot share a github repo. If only janus specific logic then the controller code is only that:
class VideoConferenceController extends FxController {
bool dataFetched = false;
bool isFrontCameraSelected = true;
bool isVideoOn = true;
bool isAudioOn = true;
late WaitingLobbyMinionController minionWaitingRoomController;
late RTCPeerConnection pc;
late RTCSessionDescription offer;
String memberRole;
late VideoCallModel videoCallModel;
bool joined = false;
String? myPin;
late dynamic joiningDialog;
GlobalKey joinForm = GlobalKey();
int? myPvtId;
JanusVideoRoomPlugin? videoPlugin;
JanusVideoRoomPlugin? screenPlugin;
JanusVideoRoomPlugin? remotePlugin;
JanusSession? session;
WebSocketJanusTransport? ws;
RestJanusTransport? rt;
JanusClient? client;
bool videoEnabled = true;
bool audioEnabled = true;
dynamic fullScreenDialog;
bool front = true;
List listOfParticipants = [];
List listOfLateMinions = [];
late StreamRenderer localVideoRenderer;
late String turnUsername;
late String turnPassword;
late IO.Socket socket;
String? timer;
late int timerValue;
final SocketService socketService = SocketService();
VideoRoomPluginStateManager videoState = VideoRoomPluginStateManager();
late Member member;
String? filePath;
int? sessionId;
int? handleId;
late FxController waitingLobbyController;
String? dealAgreementId;
late int newLiveStatus;
int numOfParticipant = 0;
VideoConferenceController(
{required this.memberRole, required this.dealAgreementId});
@OverRide
Future initState() async {
super.initState();
member = MemberCache.loginedMember!;
await initialize();
await joinRoom();
await initSocket();
log("timerStarted value: ${videoCallModel.timerStarted}");
log("timerValue is: ${videoCallModel.timerValue}");
if (!videoCallModel.timerStarted) {
initialiseTimer(videoCallModel.roomId, videoCallModel.timerValue!);
videoCallModel.timerStarted = true;
}
//join the timer room
try {
log("Video call model room Id here is: ${videoCallModel.roomId}");
socketService.joinTimerRoom(videoCallModel.roomId);
} catch (e) {
log("Error in socketService joinTimerRoom: $e");
}
}
Future prepareVideoCallRoom(String memberId) async {
try {
final request = http.MultipartRequest(
'POST', Uri.parse('$janusPhpApiUrl/------------'));
request.headers['Content-Type'] = 'multipart/form-data';
request.fields['janus_start'] = '1';
request.fields['deal_agreement_id'] = dealAgreementId!;
request.fields['session_id'] = sessionId.toString();
request.fields['handle_id'] = handleId.toString();
request.fields['participant_id'] = memberId;
log("It comes here!");
final response = await request.send();
final responseBody = await http.Response.fromStream(response);
if (responseBody.statusCode == 200) {
log('Request was successful!');
final decodedResponse = json.decode(responseBody.body);
log("returned from backend: $decodedResponse");
if (decodedResponse['err'] == '000') {
try {
videoCallModel = VideoCallModel(
roomId: decodedResponse['room_id'],
isAudioOn: true,
isVideoOn: true,
isFrontCameraSelected: true,
sessionId: sessionId.toString(),
handleId: handleId.toString(),
timerValue: int.parse(decodedResponse['timerValue']),
timerStarted: false,
);
} catch (e) {
log("Error in VideoCallModel controller: $e");
}
log("ROOM create");
log("TimerValue: $timerValue");
update();
} else if (decodedResponse['err'] == '002') {
try {
if (decodedResponse['new_duration'] == null) {
videoCallModel = VideoCallModel(
roomId: int.parse(decodedResponse['room_id']),
isAudioOn: true,
isVideoOn: true,
isFrontCameraSelected: true,
sessionId: sessionId.toString(),
handleId: handleId.toString(),
timerStarted: true,
);
} else {
videoCallModel = VideoCallModel(
roomId: int.parse(decodedResponse['room_id']),
isAudioOn: true,
isVideoOn: true,
isFrontCameraSelected: true,
sessionId: sessionId.toString(),
handleId: handleId.toString(),
timerValue: int.parse(decodedResponse['new_duration']),
timerStarted: false,
);
log("Value of timerValue here: ${videoCallModel.timerValue}");
}
} catch (e) {
log("Error in VideoCallModel controller: $e");
}
log("ROOM create");
update();
} else {
log('Error occurred: ${decodedResponse['return']}');
}
} else {
log('Request failed with status: ${response.statusCode}.');
}
} catch (error) {
log('Error happened: $error');
}
}
void initialiseTimer(int roomId, int timerValue) {
try {
//start the timer
socketService.startTimer(roomId, timerValue);
} catch (e) {
log("Error: $e");
}
}
initialize() async {
ws = WebSocketJanusTransport(url: wsJanusUrl);
client = JanusClient(
transport: ws!,
withCredentials: true,
apiSecret: "janusrocks",
isUnifiedPlan: true,
iceServers: [
RTCIceServer(
urls: "",
username: "",
credential: "",
)
],
loggerLevel: Level.FINE);
log("Client in initialize(): ${client}");
session = await client?.createSession();
sessionId = session?.sessionId;
log("sessionId: $sessionId");
log("Session in initialize(): $session");
if (session != null) {
await initLocalMediaRenderer();
dataFetched = true;
update();
} else {
print("Session is null!");
}
}
initSocket() async {
try {
socketService.connect("timer-socket");
// Listening for timer updates
socketService.socket.on('timer-update', (data) {
log('Received timer update: $data');
try {
timer = data.toString();
} catch (e) {
log("Error with timer variable: $e");
}
log("timer variable update: $timer");
// Update your UI with the timer data or trigger any logic
update();
});
socketService.socket.on('timer-error', (data) {
log("Timer error is: $data");
});
socketService.socket.on('timer-ended', (_) async {
log('Timer has ended');
//leave the room
socketService.leaveTimerRoom(videoCallModel.roomId);
//close the websocket connection:
socketService.disconnect();
Navigator.pop(context);
// End the call
await callEnd();
try {
if (memberRole == "boss") {
await changeLiveStatus();
newLiveStatus = 1;
} else {
log("Change live status did not get triggered");
}
} catch (e) {
log("Error in changeLiveStatus: $e");
}
update();
});
if (memberRole != "boss") {
socketService.socket.on('get-out', (_) async {
//leave the room
socketService.leaveTimerRoom(videoCallModel.roomId);
//close the websocket connection:
socketService.disconnect();
Navigator.pop(context);
await callEnd();
});
}
socketService.socket.on("participants-update", (data) async {
if (data != numOfParticipant) {
numOfParticipant = data;
log("Number of participants: $numOfParticipant");
//eventMessagesHandler();
}
});
socketService.socket.onConnect((data) {
log("On Connect Message: $data");
});
socketService.socket.onConnectError((data) {
log("On Connect Error: $data");
});
socketService.socket.onConnectTimeout((data) {
log("On Connect Timeout: $data");
});
socketService.socket.onDisconnect((data) {
log("Disconnected message: $data");
});
} catch (e) {
log("Error in initSocket: $e");
}
}
callEnd() async {
for (var feed in videoState.feedIdToDisplayStreamsMap.entries) {
await unSubscribeTo(feed.key);
}
videoState.streamsToBeRendered.forEach((key, value) async {
await value.dispose();
});
videoState.streamsToBeRendered.clear();
videoState.feedIdToDisplayStreamsMap.clear();
videoState.subStreamsToFeedIdMap.clear();
videoState.feedIdToMidSubscriptionMap.clear();
joined = false;
await videoPlugin?.hangup();
await videoPlugin?.dispose();
await screenPlugin?.dispose();
await remotePlugin?.dispose();
remotePlugin = null;
audioEnabled = true;
videoEnabled = true;
if (memberRole == "boss") {
socketService.kickAllOut(videoCallModel.roomId);
}
socketService.leaveTimerRoom(videoCallModel.roomId);
update();
}
muteOrStopCamera(
RTCPeerConnection? peerConnection, String kind, bool enabled) async {
try {
var senders = await peerConnection?.getSenders();
var targetSender =
senders?.firstWhere((sender) => sender.track?.kind == kind);
if (targetSender == null) {
log("Sender for $kind not found");
return;
}
if (kind == "video") {
if (enabled) {
targetSender.track?.enabled = true;
} else {
// maybe can also replace the track with a black screen or placeholder image later here
targetSender.track?.enabled = false;
}
} else if (kind == "audio") {
targetSender.track?.enabled = enabled;
}
} catch (e) {
log("Error in mute: $e");
}
}
initLocalMediaRenderer() async {
try {
localVideoRenderer = StreamRenderer('local');
} catch (e) {
print("Error with localVideoRenderer: $e");
}
update();
}
int generateUniqueId(String userId) {
// Get the current timestamp
int timestamp = DateTime.now().millisecondsSinceEpoch;
// Convert to string and take, for instance, the last 6 digits
String timestampStr =
timestamp.toString().substring(timestamp.toString().length - 6);
// Concatenate and convert back to int
int uniqueId = int.parse(userId + timestampStr);
return uniqueId;
}
joinRoom() async {
try {
await initLocalMediaRenderer();
} catch (e) {
print("Error in initLocalMediaRenderer in joinRoom: $e");
}
try {
videoPlugin = await attachPlugin(pop: true);
} catch (e) {
print("Error in attachPlugin: $e");
}
try {
await prepareVideoCallRoom(member.memberId);//http api to create video room and return the room id
} catch (e) {
log("Error in prepareVideoCallRoom: $e");
}
try {
await eventMessagesHandler();
} catch (e) {
print("Error in eventMessagesHandler in joinRoom: $e");
}
try {
await localVideoRenderer.init();
} catch (e) {
print("Error in localVideoRenderer.init(): $e");
}
try {
localVideoRenderer.mediaStream = await videoPlugin?.initializeMediaDevices(simulcastSendEncodings: [
RTCRtpEncoding(active: true, rid: 'h', maxBitrate: 4000000)], mediaConstraints: {
'video': {
'width': {'ideal': 1920},
'height': {'ideal': 1080}
},
'audio': true
});
// await videoPlugin?.initializeMediaDevices(simulcastSendEncodings: [
// RTCRtpEncoding(active: true, rid: 'h', maxBitrate: 4000000),
// RTCRtpEncoding(
// active: true,
// rid: 'm',
// maxBitrate: 1000000,
// scaleResolutionDownBy: 2),
// RTCRtpEncoding(
// active: true,
// rid: 'l',
// maxBitrate: 1000000,
// scaleResolutionDownBy: 3),
// ], mediaConstraints: {
// 'video': {
// 'width': {'ideal': 1920},
// 'height': {'ideal': 1080}
// },
// 'audio': true
// });
} catch (e) {
print("Error in joinRoom local video renderer media stream: $e");
}
print(
"Local Video Renderer mediaStream in joinRoom: ${localVideoRenderer.mediaStream}");
if (localVideoRenderer.mediaStream != null) {
print(
"Local Video Renderer mediaStream in joinRoom: ${localVideoRenderer.mediaStream}");
localVideoRenderer.videoRenderer.srcObject =
localVideoRenderer.mediaStream;
localVideoRenderer.publisherName = "You";
localVideoRenderer.publisherId = member.memberId;
localVideoRenderer.videoRenderer.onResize = () {
// to update widthxheight when it renders
update();
};
update();
videoState.streamsToBeRendered
.putIfAbsent('local', () => localVideoRenderer);
print("Room id is: $videoCallModel.roomId");
print("Video Plugin here: ${videoState.streamsToBeRendered}");
try {
await videoPlugin?.joinPublisher(videoCallModel.roomId,
displayName: member.name, id: int.parse(member.memberId));
//need to send request to perform the rtp_forward here:
//requestRtpForward(videoCallModel.roomId, int.parse(member.memberId));
} catch (e) {
print("Error in joinPublisher in joinRoom: $e");
}
} else {
print("Local Video Renderer mediaStream is null!");
}
joined = true;
update();
}
attachPlugin({bool pop = false}) async {
log("Value of pop without the pop up: $pop");
JanusVideoRoomPlugin? videoPlugin =
await session?.attach();
handleId = videoPlugin?.handleId;
videoPlugin?.typedMessages?.listen((event) async {
Object data = event.event.plugindata?.data;
log("Data in attachPlugin: $data");
if (data is VideoRoomJoinedEvent) {
myPvtId = data.privateId;
log("Private id: ${data.privateId}");
(await videoPlugin.configure(
bitrate: 4000000,
sessionDescription: await videoPlugin.createOffer(
audioRecv: false, videoRecv: false)));
}
if (data is VideoRoomLeavingEvent) {
unSubscribeTo(data.leaving!);
} else if (data is VideoRoomUnPublishedEvent) {
unSubscribeTo(data.unpublished);
}
videoPlugin.handleRemoteJsep(event.jsep);
});
log("Video Plugin at the end of attachplugin: $videoPlugin"); //ok
return videoPlugin;
}
Future unSubscribeTo(int id) async {
log("Id in unsubscribeTo: $id");
var feed = videoState.feedIdToDisplayStreamsMap[id];
log("Feed in unsubscribeTo: ${feed['streams']}");
if (feed == null) {
log("Feed is null");
return;
}
videoState.streamsToBeRendered.remove(id.toString());
update();
videoState.feedIdToDisplayStreamsMap.remove(id.toString());
await videoState.streamsToBeRendered[id]?.dispose();
var unsubscribeStreams = (feed['streams'] as List).map((stream) {
return SubscriberUpdateStream(
feed: id, mid: stream['mid'], crossrefid: null);
}).toList();
log("Feed stream: ${feed['streams']}");
if (remotePlugin != null) {
await remotePlugin?.update(unsubscribe: unsubscribeStreams);
log("Triggered remotePlugin update unsubscribe");
}
videoState.feedIdToMidSubscriptionMap.remove(id);
}
eventMessagesHandler() async {
videoPlugin?.messages?.listen((payload) async {
JanusEvent event = JanusEvent.fromJson(payload.event);
log("Payload: ${payload}");
List? publishers = event.plugindata?.data['publishers'];
await attachSubscriberOnPublisherChange(publishers);
});
videoPlugin?.renegotiationNeeded?.listen((event) async {
if (videoPlugin?.webRTCHandle?.peerConnection?.signalingState !=
RTCSignalingState.RTCSignalingStateStable) return;
print('retrying to connect publisher');
var offer = await videoPlugin?.createOffer(
audioRecv: false,
videoRecv: false,
);
await videoPlugin?.configure(sessionDescription: offer);
});
}
attachSubscriberOnPublisherChange(List? publishers) async {
if (publishers != null) {
List<List> sources = [];
for (Map publisher in publishers) {
if ([member.memberId].contains(publisher['id'])) {
continue;
}
videoState.feedIdToDisplayStreamsMap[publisher['id']] = {
'id': publisher['id'],
'display': publisher['display'],
'streams': publisher['streams']
};
List mappedStreams = [];
for (Map stream in publisher['streams'] ?? []) {
if (stream['disabled'] == true) {
manageMuteUIEvents(stream['mid'], stream['type'], true);
} else {
manageMuteUIEvents(stream['mid'], stream['type'], false);
}
if (videoState.feedIdToMidSubscriptionMap[publisher['id']] != null &&
videoState.feedIdToMidSubscriptionMap[publisher['id']]
?[stream['mid']] ==
true) {
continue;
}
stream['id'] = publisher['id'];
stream['display'] = publisher['display'];
mappedStreams.add(stream);
log("New publisher id: ${publisher['id']}");
}
sources.add(mappedStreams);
}
await subscribeTo(sources);
}
}
subscribeTo(List<List> sources) async {
if (sources.isEmpty) {
log("Sources is empty");
return;
}
if (remotePlugin == null) {
log("RemotePlugin in subscribeTo is null");
remotePlugin = await session?.attach();
remotePlugin?.messages?.listen((payload) async {
JanusEvent event = JanusEvent.fromJson(payload.event);
List? streams = event.plugindata?.data['streams'];
log("event data from janus: $event");
streams?.forEach((element) {
videoState.subStreamsToFeedIdMap[element['mid']] = element;
// to avoid duplicate subscriptions
if (videoState.feedIdToMidSubscriptionMap[element['feed_id']] == null)
videoState.feedIdToMidSubscriptionMap[element['feed_id']] = {};
videoState.feedIdToMidSubscriptionMap[element['feed_id']]
[element['mid']] = true;
});
if (payload.jsep != null) {
await remotePlugin?.handleRemoteJsep(payload.jsep);
await remotePlugin?.start(videoCallModel.roomId);
}
});
remotePlugin?.remoteTrack?.listen((event) async {
print({
'mid': event.mid,
'flowing': event.flowing,
'id': event.track?.id,
'kind': event.track?.kind
});
int? feedId = videoState.subStreamsToFeedIdMap[event.mid]?['feed_id'];
String? displayName =
videoState.feedIdToDisplayStreamsMap[feedId]?['display'];
if (feedId != null) {
if (videoState.streamsToBeRendered.containsKey(feedId.toString()) &&
event.track?.kind == "audio") {
var existingRenderer =
videoState.streamsToBeRendered[feedId.toString()];
existingRenderer?.mediaStream?.addTrack(event.track!);
existingRenderer?.videoRenderer.srcObject =
existingRenderer.mediaStream;
existingRenderer?.videoRenderer.muted = false;
update();
}
if (!videoState.streamsToBeRendered.containsKey(feedId.toString()) &&
event.track?.kind == "video") {
var localStream = StreamRenderer(feedId.toString());
await localStream.init();
localStream.mediaStream =
await createLocalMediaStream(feedId.toString());
localStream.mediaStream?.addTrack(event.track!);
localStream.videoRenderer.srcObject = localStream.mediaStream;
localStream.videoRenderer.onResize = () => {update()};
localStream.publisherName = displayName;
localStream.publisherId = feedId.toString();
localStream.mid = event.mid;
videoState.streamsToBeRendered
.putIfAbsent(feedId.toString(), () => localStream);
update();
}
}
});
List<PublisherStream> streams = sources
.map((e) => e.map((e) => PublisherStream(
feed: e['id'], mid: e['mid'], simulcast: e['simulcast'])))
.expand((element) => element)
.toList();
await remotePlugin?.joinSubscriber(videoCallModel.roomId,
streams: streams,
feedId: int.parse(member.memberId),
privateId: myPvtId);
return;
}
List<Map>? added = null, removed = null;
for (var streams in sources) {
for (var stream in streams) {
// If the publisher is VP8/VP9 and this is an older Safari, let's avoid video
if (stream['disabled'] != null) {
print("Disabled stream:");
// Unsubscribe
if (removed == null) removed = [];
removed.add({
'feed': stream['id'], // This is mandatory
'mid': stream['mid'] // This is optional (all streams, if missing)
});
videoState.feedIdToMidSubscriptionMap[stream['id']]
?.remove(stream['mid']);
videoState.feedIdToMidSubscriptionMap.remove(stream['id']);
continue;
}
if (videoState.feedIdToMidSubscriptionMap[stream['id']] != null &&
videoState.feedIdToMidSubscriptionMap[stream['id']]
[stream['mid']] ==
true) {
print("Already subscribed to stream, skipping:");
continue;
}
// Subscribe
if (added == null) added = [];
added.add({
'feed': stream['id'], // This is mandatory
'mid': stream['mid'] // This is optional (all streams, if missing)
});
if (videoState.feedIdToMidSubscriptionMap[stream['id']] == null)
videoState.feedIdToMidSubscriptionMap[stream['id']] = {};
videoState.feedIdToMidSubscriptionMap[stream['id']][stream['mid']] =
true;
}
}
if ((added == null || added.length == 0) &&
(removed == null || removed.length == 0)) {
log("added is null or removed is null");
// Nothing to do
return;
} else {
log("Problem is elsewhere");
}
try {
await remotePlugin?.update(
subscribe: added
?.map((e) => SubscriberUpdateStream(
feed: e['feed'], mid: e['mid'], crossrefid: null))
.toList(),
unsubscribe: removed
?.map((e) => SubscriberUpdateStream(
feed: e['feed'], mid: e['mid'], crossrefid: null))
.toList());
} catch (e) {
log("Got error in plugin update: $e");
}
}
@OverRide
void dispose() async {
super.dispose();
session?.dispose();
socketService.disconnect();
}
@OverRide
String getTag() {
return "VideoConferenceController";
}
}
I would suggest you track what events you receive from Janus at every step of the way when the user tries to rejoin maybe even use the debugger to see what's going wrong as the code that you gave here is unformatted and it's hard to make sense of it, since your code involves custom logic so point of issue can be anywhere if you can give me basic reproducible example of this issue just having janus code and emulated functionality of your logic it would be great help to test and debug it. because the example code works as expected as you see it
Closing because of inactivity
Hi, using an example from the repository I ran into the same problem.
An error occurred when trying to connect to the room
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Unable to RTCPeerConnection::addTransceiver: C++ addTransceiver failed.
E/flutter (14762): #0 RTCPeerConnectionNative.addTransceiver (package:flutter_webrtc/src/native/rtc_peerconnection_impl.dart:619:7)
E/flutter (14762): <asynchronous suspension>
E/flutter (14762): #1 JanusPlugin.initializeMediaDevices.<anonymous closure> (package:janus_client/janus_plugin.dart:463:13)
E/flutter (14762): <asynchronous suspension>
E/flutter (14762):
There was only sound
By changing the code
localVideoRenderer.mediaStream = await videoPlugin?.initializeMediaDevices(mediaConstraints: {
'video': true,
'audio': true
});
I managed to connect, but when reconnecting, there was no video on the second device or there was a black screen with an error
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: PlatformException(error, MediaStreamTrack has been disposed., null, java.lang.IllegalStateException: MediaStreamTrack has been disposed.
E/flutter ( 2728): at org.webrtc.MediaStreamTrack.checkMediaStreamTrackExists(MediaStreamTrack.java:120)
E/flutter ( 2728): at org.webrtc.MediaStreamTrack.kind(MediaStreamTrack.java:88)
E/flutter ( 2728): at com.cloudwebrtc.webrtc.MethodCallHandlerImpl.mediaStreamAddTrack(MethodCallHandlerImpl.java:1532)
E/flutter ( 2728): at com.cloudwebrtc.webrtc.MethodCallHandlerImpl.onMethodCall(MethodCallHandlerImpl.java:462)
E/flutter ( 2728): at io.flutter.plugin.common.MethodChannel$IncomingMethodCallHandler.onMessage(MethodChannel.java:258)
E/flutter ( 2728): at io.flutter.embedding.engine.dart.DartMessenger.invokeHandler(DartMessenger.java:295)
E/flutter ( 2728): at io.flutter.embedding.engine.dart.DartMessenger.lambda$dispatchMessageToQueue$0$io-flutter-embedding-engine-dart-DartMessenger(DartMessenger.java:322)
E/flutter ( 2728): at io.flutter.embedding.engine.dart.DartMessenger$$ExternalSyntheticLambda0.run(Unknown Source:12)
E/flutter ( 2728): at android.os.Handler.handleCallback(Handler.java:942)
E/flutter ( 2728): at android.os.Handler.dispatchMessage(Handler.java:99)
E/flutter ( 2728): at android.os.Looper.loopOnce(Looper.java:240)
E/flutter ( 2728): at android.os.Looper.loop(Looper.java:351)
E/flutter ( 2728): at android.app.ActivityThread.main(ActivityThread.java:8422)
E/flutter ( 2728): at java.lang.reflect.Method.invoke(Native Method)
E/flutter ( 2728): at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:584)
E/flutter ( 2728): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1013)
E/flutter ( 2728): )
E/flutter ( 2728): #0 StandardMethodCodec.decodeEnvelope (package:flutter/src/services/message_codecs.dart:652:7)
E/flutter ( 2728): #1 MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:310:18)
E/flutter ( 2728): <asynchronous suspension>
E/flutter ( 2728): #2 MediaStreamNative.addTrack (package:flutter_webrtc/src/native/media_stream_impl.dart:61:7)
E/flutter ( 2728): <asynchronous suspension>
@oleghromanov what platform are you using to run the Google meet example?
@oleghromanov what platform are you using to run the Google meet example?
Android, OnePlus phone and emulator
@oleghromanov can you verify if it works for you?