1

I am developing a streaming application: one application (server) broadcasts a video, and another application (client) displays this video. I used the flutter_webrtc package for real-time communication. I followed the following tutroial:
https://www.youtube.com/watch?v=hAKQzNQmNe0
https://github.com/md-weber/webrtc_tutorial/

Currently, the server application can successfully create a channel and broadcast the video, and the client application can join this channel and watch the video. But when the client leaves the channel and later tries to connect to the same channel, it can't get the video, only a black screen is displayed. There are no errors shown.

I used flutter_riverpod as state management and all codes below are inside StateNotifiers.

Function to create a channel from server application side.

Future<String> startStream(RTCVideoRenderer localVideo) async {
  state = ProcessState.loading;

  final stream = await navigator.mediaDevices.getUserMedia(<String, dynamic>{
    'video': true,
    'audio': true,
  });

  localVideo.srcObject = stream;
  localStream = stream;

  final roomId = await _createRoom();
  await Wakelock.enable();
  state = ProcessState.working;

  return roomId;
}
Future<String> _createRoom() async {
  final db = FirebaseFirestore.instance;
  final roomRef = db.collection('rooms').doc();

  peerConnection = await createPeerConnection(configuration);

  localStream?.getTracks().forEach((track) {
    peerConnection?.addTrack(track, localStream!);
  });

  // Code for collecting ICE candidates below
  final callerCandidatesCollection = roomRef.collection('callerCandidates');

  peerConnection?.onIceCandidate = (RTCIceCandidate candidate) {
    callerCandidatesCollection.add(candidate.toMap() as Map<String, dynamic>);
  };

  // Add code for creating a room
  final offer = await peerConnection!.createOffer();
  await peerConnection!.setLocalDescription(offer);

  final roomWithOffer = <String, dynamic>{'offer': offer.toMap()};

  await roomRef.set(roomWithOffer);
  roomId = roomRef.id;

  // Listening for remote session description below
  roomRef.snapshots().listen((snapshot) async {
    final data = snapshot.data();

    if (peerConnection?.getRemoteDescription() != null && data != null && data['answer'] != null){
      final answer = data['answer'] as Map<String, dynamic>;
      final description = RTCSessionDescription(
        answer['sdp'] as String?,
        answer['type'] as String?,
      );

      await peerConnection?.setRemoteDescription(description);
    }
  });

  // Listen for remote Ice candidates below
  roomRef.collection('calleeCandidates').snapshots().listen((snapshot) {
    for (final change in snapshot.docChanges) {
      if (change.type == DocumentChangeType.added) {
        final data = change.doc.data();

        peerConnection!.addCandidate(
          RTCIceCandidate(
            data?['candidate'] as String?,
            data?['sdpMid'] as String?,
            data?['sdpMLineIndex'] as int?,
          ),
        );
      }
    }
  });

  return roomId!;
}

Function to join a channel from client application side.

Future<bool> startStream(String? roomId, RTCVideoRenderer remoteVideo) async {
  if (roomId == null || roomId.isEmpty) {
    return false;
  }

  state = ProcessState.loading;
  final result = await _joinRoom(roomId, remoteVideo);

  if (result) {
    state = ProcessState.working;

    await Wakelock.enable();
  } else {
    state = ProcessState.notInitialized;
  }

  return result;
}
Future<bool> _joinRoom(String roomId, RTCVideoRenderer remoteVideo) async {
  final db = FirebaseFirestore.instance;
  final DocumentReference roomRef = db.collection('rooms').doc(roomId);
  final roomSnapshot = await roomRef.get();

  if (roomSnapshot.exists) {
    peerConnection = await createPeerConnection(configuration);
    
    peerConnection?.onAddStream = (MediaStream stream) {
      onAddRemoteStream?.call(stream);
      remoteStream = stream;
    };

    // Code for collecting ICE candidates below
    final calleeCandidatesCollection = roomRef.collection('calleeCandidates');
    peerConnection?.onIceCandidate = (RTCIceCandidate candidate) {
      final candidateMap = candidate.toMap() as Map<String, dynamic>;
      calleeCandidatesCollection.add(candidateMap);
    };

    peerConnection?.onTrack = (RTCTrackEvent event) {
      event.streams[0].getTracks().forEach((track) {
        remoteStream?.addTrack(track);
      });
    };

    // Code for creating SDP answer below
    final data = roomSnapshot.data() as Map<String, dynamic>?;
    final offer = data?['offer'] as Map<String, dynamic>?;

    await peerConnection?.setRemoteDescription(
      RTCSessionDescription(
        offer?['sdp'] as String?,
        offer?['type'] as String?,
      ),
    );

    final answer = await peerConnection!.createAnswer();
    await peerConnection!.setLocalDescription(answer);

    final roomWithAnswer = <String, dynamic>{
      'answer': {
        'type': answer.type,
        'sdp': answer.sdp,
      }
    };

    await roomRef.update(roomWithAnswer);

    // Listening for remote ICE candidates below
    roomRef.collection('callerCandidates').snapshots().listen((snapshot) {
      for (final document in snapshot.docChanges) {
        final data = document.doc.data();

        peerConnection!.addCandidate(
          RTCIceCandidate(
            data?['candidate'] as String?,
            data?['sdpMid'] as String?,
            data?['sdpMLineIndex'] as int?,
          ),
        );
      }
    });

    this.roomId = roomId;

    return true;
  }

  return false;
}
Kasymbek R. Tashbaev
  • 1,154
  • 1
  • 4
  • 16

1 Answers1

1

After some research, I found that my problem is the same as in the question Failed to set remote answer sdp: Called in wrong state: stable. This was caused by the fact that one RTCPeerConnection can only establish one peer-to-peer connection. So I could fix this by creating a new RTCPeerConnection on the server side every time a new client wants to join the channel.

Future<String> _createRoom() async {
    final db = FirebaseFirestore.instance;
    final roomRef = db.collection('rooms').doc();
    await newPeerConnection(roomRef);

    roomId = roomRef.id;

    // Listening for remote session description below
    roomRef.snapshots().listen((snapshot) async {
      final data = snapshot.data();

      if (data != null && data['answer'] != null) {
        final answer = data['answer'] as Map<String, dynamic>;
        final description = RTCSessionDescription(
          answer['sdp'] as String?,
          answer['type'] as String?,
        );

        await peerConnectionList.last.setRemoteDescription(description);
        await newPeerConnection(roomRef);
      }
    });

    // Listen for remote Ice candidates below
    roomRef.collection('calleeCandidates').snapshots().listen((snapshot) {
      for (final change in snapshot.docChanges) {
        if (change.type == DocumentChangeType.added) {
          final data = change.doc.data();

          peerConnectionList.last.addCandidate(
            RTCIceCandidate(
              data?['candidate'] as String?,
              data?['sdpMid'] as String?,
              data?['sdpMLineIndex'] as int?,
            ),
          );
        }
      }
    });

    return roomId!;
}

  Future<void> newPeerConnection(DocumentReference roomRef) async {
    final peerConnection = await createPeerConnection(
      configuration,
      offerSdpConstraints,
    );

    _registerPeerConnectionListeners(peerConnection);

    localStream?.getTracks().forEach((track) {
      peerConnection.addTrack(track, localStream!);
    });

    final offer = await peerConnection.createOffer();
    await peerConnection.setLocalDescription(offer);

    final roomWithOffer = <String, dynamic>{'offer': offer.toMap()};
    await roomRef.set(roomWithOffer);

    peerConnectionList.add(peerConnection);
  }
Kasymbek R. Tashbaev
  • 1,154
  • 1
  • 4
  • 16