WebRTC - Comunicación en tiempo real para la Web

En el modelo cliente-servidor, cuando se establece la comunicación entre dos clientes, se está obligado a soportar un retardo que aparece hasta que la trama de datos del cliente 1 llega al servidor y, a continuación, al cliente 2. Para eliminar este retardo, son adecuadas las conexiones peer-to-peer (P2P), en las que los clientes se comunican (transfieren datos) entre sí directamente.

Vamos a utilizar este repositorio en nuestro artículo. No olvides dejar allí una ⭐️.

GitHub - flutterwtf/Flutter-WebRTC
Contribute to flutterwtf/Flutter-WebRTC development by creating an account on GitHub.

WebRTC es un proyecto de código abierto que permite el intercambio directo P2P sin necesidad de instalar programas o plugins adicionales. Soportado por todos los navegadores populares hoy en día, está construido sobre la base de UDP. No tiene sentido para nosotros profundizar en la pila, estamos más interesados en el proceso de instalación y uso de dicha conexión.

Para establecer una conexión P2P, debemos conocer la dirección IP del compañero para poder intercambiar datos con él y él debe conocer nuestra IP. El protocolo STUN (Session Traversal Utilities for NAT) nos ayudará con esto. No nos detendremos en él, pero en resumen, los servidores STUN le permiten determinar su dirección IP pública y el puerto por el que se le puede contactar desde la red externa.

En Flutter, proporcionamos servidores STUN cuando intentamos crear una  RTCPeerConnection:

import 'package:flutter_webrtc/flutter_webrtc.dart';

static const Map<String, dynamic> _configuration = {
  'iceServers': [
    {
      'urls': [
        'stun:stun1.l.google.com:19302',
        'stun:stun2.l.google.com:19302',
      ],
    },
  ],
};

final peerConnection = await createPeerConnection(_configuration);

Una conexión no puede establecerse sin el intercambio de configuraciones especiales (objetos RTCSessionDescription), por lo que dicho intercambio se realiza con la ayuda de un servidor. En nuestro caso, para un inicio rápido y simplicidad, utilizaremos herramientas Firebase.

Explicación más detallada

(puede encontrar la explicación del código debajo de esta tabla)

Usuario 1 (crea habitación) Estado de Firestore Usuario 2
1 Inicializa RTCPeerConnection objeto
2 Crea RTCSessionDescription oferta de RTCPeerConnection objeto y envíelo a Firestone
3 Establezca controladores de eventos, específicamente onAddStream, onIceCandidate y onTrack (descripción a continuación)
Objeto de habitación:
offer: ‘some-long-config-data’
4 Establezca la descripción local para RTCSessionDescription, después de que onIceCandidate el controlador se está activando y envía candidatos a Firestore igual que antes
Objeto de habitación:
offer: ‘some-long-config-data’ candidates:
[
roomCreatorCandidate1,
roomCreatorCandidate2,
...
]
5 Comience a escuchar para obtener una respuesta y otros candidatos de usuarios igual que antes
6 igual que antes Maneje la entrada del usuario y getroomID, verifique si la habitación con dicha identificación existe en Firestore, en caso afirmativo, busque el objeto de oferta
7 igual que antes Inicializa RTCPeerConnection object
8 igual que antes Establezca controladores de eventos, específicamente onAddStream, onIceCandidate yonTrack
9 igual que antes Establezca descripción remota para RTCSessionDescription
10 igual que antes Crea RTCSessionDescription respuesta de RTCPeerConnection objeto y envíalo a Firestone
Objeto de la habitación:
offer: ‘some-long-config-data’
answer: ‘other-long-config-data’
candidates:
[
roomCreatorCandidate1,
roomCreatorCandidate2,
...
]
11 igual que antes Establezca la descripción local para RTCSessionDescription, después de que onIceCandidate el controlador se está activando y envía candidatos a Firestore
Objeto de la habitación:
offer: ‘some-long-config-data’
answer: ‘other-long-config-data’
candidates:
[
roomCreatorCandidate1,
roomCreatorCandidate2,
...roomJoinerCandidate1,
roomJoinerCandidate2,
...
]
12 igual que antes Empieza a escuchar candidatos a creador de salas y añádelos a RTCPeerConnection
13 Se está activando el oyente de respuesta, después de qué descripción remota para RTCSessionDescription se establece igual que antes
14 Otros usuarios candidatos receptores están siendo activado, después de lo que los nuevos candidatos se están añadiendo a RTCPeerConnection igual que antes

Descripción de los controladores:

onAddStream el evento se envía a una RTCPeerConnection cuando se le ha agregado un nuevo medio, en forma de un objeto MediaStream
onIceCandidate se envía a un RTCPeerConnection cuando un RTCIceCandidate se ha identificado y agregado al par local mediante una llamada a RTCPeerConnection.setLocalDescription().

El controlador de eventos debe transmitir el candidato al par remoto a través del canal de señalización para que el par remoto pueda agregarlo a su conjunto de candidatos remotos
onTrack El evento se envía al controlador enRTCPeerConnection(todas las conexiones reciben este evento) después de que se haya agregado una nueva pista a unRTCRtpReceiver que es parte de la conexión

Todo hecho, ahora ambos usuarios saben el uno del otro y tienen objetos RTCPeerConnectiontotalmente configurados. Cuando  RTCPeerConnection recibe el objeto  MediaStreamTrack, el controlador  onTrack  añade esta pista al  MediaStream  existente que, a su vez, puede utilizarse como fuente para  RTCVideoRenderer.

Implementación de WebRTC en Su Aplicación

Permisos

Añada las siguientes líneas a ios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) Camera Usage!</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) Microphone Usage!</string>

A continuación, añadir características y permisos a android/app/src/main/AndroidManifest.xml:

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

Objetos RTCVideoRenderer

Añade flutter_webrtc como dependencia en tu archivo pubspec.yaml.

Declara e inicializa dos objetos, uno para el usuario local y otro para el remoto:

import 'package:flutter_webrtc/flutter_webrtc.dart';

final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();

@override
void initState() {
	_localRenderer.initialize();
	_remoteRenderer.initialize();
	super.initState();
}

@override
  void dispose() {
    _localRenderer.dispose();
    _remoteRenderer.dispose();
    super.dispose();
  }

El  RTCVideoRenderer nos permite reproducir fotogramas de vídeo obtenidos de la pista de vídeo WebRTC. Dependiendo de la fuente de la pista de vídeo, puede reproducir vídeos de un par local o de uno remoto.

Activación de la cámara y el micro

import 'package:flutter_webrtc/flutter_webrtc.dart';

Future<void> enableUserMediaStream() async {
  var stream = await navigator.mediaDevices.getUserMedia(
		{'video': true, 'audio': true},
	);
  emit(state.copyWith(localStream: stream));
}

Creación de la habitación

Inicializar el objeto  RTCPeerConnection  (paso 1 de la tabla):

import 'package:flutter_webrtc/flutter_webrtc.dart';

Future<void> _createPeerConnection() async {
  final peerConnection = await createPeerConnection(_configuration);
  emit(state.copyWith(peerConnection: peerConnection));
}

Crear oferta a partir del objeto  peerConnection y enviarla a Firestore (paso 2 de la tabla):

import 'package:flutter_webrtc/flutter_webrtc.dart';

final offer = await state.peerConnection.createOffer();
final roomId = await _firebaseDataSource.createRoom(offer: offer);

...

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';

class FirebaseDataSource {
  final FirebaseFirestore _db = FirebaseFirestore.instance;
  static const String _roomsCollection = 'rooms';

	Future<String> createRoom({required RTCSessionDescription offer}) async {
	    final roomRef = _db.collection(_roomsCollection).doc();
	    final roomWithOffer = <String, dynamic>{'offer': offer.toMap()};

	    await roomRef.set(roomWithOffer);
	    return roomRef.id;
	}
}

Añada pistas de flujo locales a  peerConnection:

import 'package:flutter_webrtc/flutter_webrtc.dart';

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

Establece los controladores de eventos (paso 3 de la tabla):

import 'package:flutter_webrtc/flutter_webrtc.dart';

void _registerPeerConnectionListeners(String roomId) {
  state.peerConnection.onIceCandidate = (candidate) {
    _firebaseDataSource.addCandidateToRoom(roomId: roomId, candidate: candidate);
  };

  state.peerConnection.onAddStream = (stream) {
    emit(state.copyWith(remoteStream: stream));
  };

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

...

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';

class FirebaseDataSource {
  final FirebaseFirestore _db = FirebaseFirestore.instance;

  static const String _roomsCollection = 'rooms';
  static const String _candidatesCollection = 'candidates';
  static const String _candidateUidField = 'uid';

	// Current user id, used to identify candidates in Firestore collection
	// You can get it from FirebaseAuth or just generate a random string, it is used just
	// to understand if a candidate belongs to another user or not
  String? userId;

  Future<void> addCandidateToRoom({
    required String roomId,
    required RTCIceCandidate candidate,
  }) async {
    final roomRef = _db.collection(_roomsCollection).doc(roomId);
    final candidatesCollection = roomRef.collection(_candidatesCollection);
    await candidatesCollection.add(candidate.toMap()..[_candidateUidField] = userId);
  }
}

Establece la descripción local y empieza a escuchar una respuesta y a otros usuarios candidatos (paso 4 - 5 de la tabla):

import 'package:flutter_webrtc/flutter_webrtc.dart';

state.peerConnection.setLocalDescription(offer);

_firebaseDataSource.getRoomDataStream(roomId: roomId).listen((answer) async {
  if (answer != null) {
    state.peerConnection.setRemoteDescription(answer);
  } else {
		// stream return value is null means that call was ended and room was deleted
    emit(clearState);
  }
});
_firebaseDataSource.getCandidatesAddedToRoomStream(roomId: roomId, listenCaller: false).listen(
  (candidates) {
    for (final candidate in candidates) {
      state.peerConnection.addCandidate(candidate);
    }
  },
);

...

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';

class FirebaseDataSource {
  final FirebaseFirestore _db = FirebaseFirestore.instance;

  static const String _roomsCollection = 'rooms';
  static const String _candidatesCollection = 'candidates';
  static const String _candidateUidField = 'uid';

  String? userId;

  Stream<RTCSessionDescription?> getRoomDataStream({required String roomId}) {
    final snapshots = _db.collection(_roomsCollection).doc(roomId).snapshots();
    final filteredStream = snapshots.map((snapshot) => snapshot.data());
    return filteredStream.map(
      (data) {
        if (data != null && data['answer'] != null) {
          return RTCSessionDescription(
            data['answer']['sdp'],
            data['answer']['type'],
          );
        } else {
          return null;
        }
      },
    );
  }

  Stream<List<RTCIceCandidate>> getCandidatesAddedToRoomStream({
    required String roomId,
    required bool listenCaller,
  }) {
    final snapshots = _db
        .collection(_roomsCollection)
        .doc(roomId)
        .collection(_candidatesCollection)
        .where(_candidateUidField, isNotEqualTo: userId)
        .snapshots();

    final convertedStream = snapshots.map(
      (snapshot) {
        final docChangesList = listenCaller
            ? snapshot.docChanges
            : snapshot.docChanges.where((change) => change.type == DocumentChangeType.added);
        return docChangesList.map((change) {
          final data = change.doc.data() as Map<String, dynamic>;
          return RTCIceCandidate(
            data['candidate'],
            data['sdpMid'],
            data['sdpMLineIndex'],
          );
        }).toList();
      },
    );

    return convertedStream;
  }
}

Uniendo habitación

Compruebe si la habitación con ID existe en Firestore, en caso afirmativo obtenga el objeto de oferta (paso 6 de la tabla):

import 'package:flutter_webrtc/flutter_webrtc.dart';

final sessionDescription = await _firebaseDataSource.getRoomOfferIfExists(roomId: roomId);

if (sessionDescription != null) {
	...
}

...

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';

class FirebaseDataSource {
  final FirebaseFirestore _db = FirebaseFirestore.instance;

  static const String _roomsCollection = 'rooms';

  Future<RTCSessionDescription?> getRoomOfferIfExists({required String roomId}) async {
    final roomDoc = await _db.collection(_roomsCollection).doc(roomId).get();
    if (!roomDoc.exists) {
      return null;
    } else {
      final data = roomDoc.data() as Map<String, dynamic>;
      final offer = data['offer'];
      return RTCSessionDescription(offer['sdp'], offer['type']);
    }
  }
}

Inicialice el objeto RTCPeerConnectionobject, configure los manejadores de eventos, añada pistas de flujo locales y configure la descripción de la sesión remota (pasos 7 - 9 de la tabla):

await _createPeerConnection();
_registerPeerConnectionListeners(roomId);

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

await state.peerConnection.setRemoteDescription(sessionDescription);

Crea una respuesta a partir del objeto  peerConnection , envíala a Firestore y establécela como descripción local para  peerConnection (paso 10 - 11 de la tabla):

import 'package:flutter_webrtc/flutter_webrtc.dart';

final answer = await state.peerConnection.createAnswer();
await state.peerConnection.setLocalDescription(answer);
await _firebaseDataSource.setAnswer(roomId: roomId, answer: answer);

...

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';

class FirebaseDataSource {
  final FirebaseFirestore _db = FirebaseFirestore.instance;
  static const String _roomsCollection = 'rooms';

	Future<void> setAnswer({
    required String roomId,
    required RTCSessionDescription answer,
  }) async {
    final roomRef = _db.collection(_roomsCollection).doc(roomId);
    final answerMap = <String, dynamic>{
      'answer': {'type': answer.type, 'sdp': answer.sdp}
    };
    await roomRef.update(answerMap);
  }
}

Empieza a escuchar a los candidatos creadores de salas:

import 'package:flutter_webrtc/flutter_webrtc.dart';

_firebaseDataSource.getCandidatesAddedToRoomStream(roomId: roomId, listenCaller: true).listen(
  (candidates) {
    for (final candidate in candidates) {
      state.peerConnection.addCandidate(candidate);
    }
  },
),
_firebaseDataSource.getRoomDataStream(roomId: roomId).listen(
  (answer) async {
    // stream return value is null means that call was ended and room was deleted
		if (answer == null) {
      emit(state.copyWith(clearAll: true));
    }
  },
)

Visualización de secuencias con objetos renderizadores

Ahora ambos usuarios tienen streams locales y remotos, y de alguna manera necesitamos establecer fuentes para nuestros objetos  RTCVideoRenderer. Se puede hacer en state listener así:

import 'package:flutter_webrtc/flutter_webrtc.dart';

if (state.localStream != null || _localRenderer.srcObject != state.localStream) {
  _localRenderer.srcObject = state.localStream!;
}
if (state.remoteStream != null || _remoteRenderer.srcObject != state.remoteStream) {
  _remoteRenderer.srcObject = state.remoteStream!;
}

Para mostrar estas secuencias en la interfaz de usuario, consulte el widget RTCVideoView widget.

¡Ya está! Ya estás preparado para crear videochat en tu aplicación.

Share this post