organize dirs
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../events.dart';
|
||||
import '../exceptions.dart';
|
||||
import '../extensions.dart';
|
||||
import '../internal/events.dart';
|
||||
import '../logger.dart';
|
||||
import '../managers/delay.dart';
|
||||
import '../managers/event.dart';
|
||||
import '../options.dart';
|
||||
import '../proto/livekit_models.pb.dart' as lk_models;
|
||||
import '../proto/livekit_rtc.pb.dart' as lk_rtc;
|
||||
import '../support/disposable.dart';
|
||||
import '../types.dart';
|
||||
import 'room.dart';
|
||||
import 'signal_client.dart';
|
||||
import 'transport.dart';
|
||||
|
||||
class Engine extends Disposable with EventsEmittable<EngineEvent> {
|
||||
static const _lossyDCLabel = '_lossy';
|
||||
static const _reliableDCLabel = '_reliable';
|
||||
static const _maxReconnectAttempts = 5;
|
||||
|
||||
// Reference to the Room
|
||||
final Room room;
|
||||
|
||||
final SignalClient signalClient;
|
||||
|
||||
@internal
|
||||
PCTransport? publisher;
|
||||
|
||||
@internal
|
||||
PCTransport? subscriber;
|
||||
|
||||
@internal
|
||||
PCTransport? get primary => _subscriberPrimary ? subscriber : publisher;
|
||||
|
||||
// data channels for packets
|
||||
rtc.RTCDataChannel? _reliableDC;
|
||||
rtc.RTCDataChannel? _lossyDC;
|
||||
rtc.RTCDataChannel? _reliableDCSub;
|
||||
rtc.RTCDataChannel? _lossyDCSub;
|
||||
|
||||
rtc.RTCDataChannelState get reliableDataChannelState =>
|
||||
_reliableDC?.state ?? rtc.RTCDataChannelState.RTCDataChannelClosed;
|
||||
|
||||
rtc.RTCDataChannelState get lossyDataChannelState =>
|
||||
_lossyDC?.state ?? rtc.RTCDataChannelState.RTCDataChannelClosed;
|
||||
bool _iceConnected = false;
|
||||
|
||||
ConnectionState _connectionState = ConnectionState.disconnected;
|
||||
|
||||
/// Connection state of the [Room].
|
||||
ConnectionState get connectionState => _connectionState;
|
||||
|
||||
// true if publisher connection has already been established.
|
||||
// this is helpful to know if we need to restart ICE on the publisher connection
|
||||
bool _hasPublished = false;
|
||||
|
||||
// remember url and token for reconnect
|
||||
String? url;
|
||||
String? token;
|
||||
|
||||
bool _subscriberPrimary = false;
|
||||
|
||||
// server-provided ice servers
|
||||
List<lk_rtc.ICEServer> _serverProvidedIceServers = [];
|
||||
|
||||
// internal
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
late final _signalListener = signalClient.createListener(synchronized: true);
|
||||
|
||||
final delays = CancelableDelayManager();
|
||||
|
||||
Engine({
|
||||
required this.room,
|
||||
SignalClient? signalClient,
|
||||
}) : signalClient = signalClient ?? SignalClient() {
|
||||
if (kDebugMode) {
|
||||
// log all EngineEvents
|
||||
events.listen((event) =>
|
||||
logger.fine('[EngineEvent] $objectId ${event.runtimeType}'));
|
||||
}
|
||||
|
||||
_setUpListeners();
|
||||
|
||||
onDispose(() async {
|
||||
await events.dispose();
|
||||
await delays.dispose();
|
||||
await close();
|
||||
await _signalListener.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<lk_rtc.JoinResponse> connect(
|
||||
String url,
|
||||
String token,
|
||||
) async {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
|
||||
// connect to rtc server
|
||||
await signalClient.connect(
|
||||
url,
|
||||
token,
|
||||
connectOptions: room.connectOptions,
|
||||
);
|
||||
|
||||
// wait for join response
|
||||
final event = await _signalListener.waitFor<SignalConnectedEvent>(
|
||||
duration: Timeouts.connection,
|
||||
onTimeout: () => throw ConnectException(),
|
||||
);
|
||||
|
||||
return event.response;
|
||||
}
|
||||
|
||||
/// Close connection between the server.
|
||||
Future<void> close() async {
|
||||
logger.fine('[$objectId] close()');
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
logger.warning('[$objectId]: close() already disconnected');
|
||||
}
|
||||
// _statsTimer.cancel();
|
||||
// cancel all ongoing delays
|
||||
await delays.cancelAll();
|
||||
|
||||
// PCTransport is responsible for disposing RTCPeerConnection
|
||||
await publisher?.dispose();
|
||||
publisher = null;
|
||||
|
||||
await subscriber?.dispose();
|
||||
subscriber = null;
|
||||
|
||||
await signalClient.close();
|
||||
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
// notifyListeners();
|
||||
}
|
||||
|
||||
@internal
|
||||
Future<lk_models.TrackInfo> addTrack({
|
||||
required String cid,
|
||||
required String name,
|
||||
required lk_models.TrackType kind,
|
||||
required lk_models.TrackSource source,
|
||||
VideoDimensions? dimensions,
|
||||
bool? dtx,
|
||||
List<lk_models.VideoLayer>? videoLayers,
|
||||
}) async {
|
||||
// TODO: Check if cid already published
|
||||
|
||||
// send request to add track
|
||||
signalClient.sendAddTrack(
|
||||
cid: cid,
|
||||
name: name,
|
||||
type: kind,
|
||||
source: source,
|
||||
dimensions: dimensions,
|
||||
dtx: dtx,
|
||||
videoLayers: videoLayers,
|
||||
);
|
||||
|
||||
// wait for response, or timeout
|
||||
final event = await _signalListener.waitFor<SignalLocalTrackPublishedEvent>(
|
||||
filter: (event) => event.cid == cid,
|
||||
duration: Timeouts.publish,
|
||||
onTimeout: () => throw TrackPublishException(),
|
||||
);
|
||||
|
||||
return event.track;
|
||||
}
|
||||
|
||||
@internal
|
||||
Future<void> negotiate({bool? iceRestart}) async {
|
||||
if (publisher == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_hasPublished = true;
|
||||
publisher!.negotiate(null);
|
||||
}
|
||||
|
||||
@internal
|
||||
Future<void> sendDataPacket(
|
||||
lk_models.DataPacket packet,
|
||||
) async {
|
||||
// make sure we do have a data connection
|
||||
await _ensurePublisherConnected();
|
||||
|
||||
// construct the data channel message
|
||||
final message =
|
||||
rtc.RTCDataChannelMessage.fromBinary(packet.writeToBuffer());
|
||||
|
||||
// chose data channel
|
||||
final rtc.RTCDataChannel? channel =
|
||||
packet.kind == lk_models.DataPacket_Kind.LOSSY ? _lossyDC : _reliableDC;
|
||||
|
||||
// send if channel exists
|
||||
if (channel == null) {
|
||||
throw UnexpectedStateException('Data channel is not ready');
|
||||
}
|
||||
|
||||
logger.fine('sendDataPacket(label:${channel.label})');
|
||||
await channel.send(message);
|
||||
}
|
||||
|
||||
Future<void> _ensurePublisherConnected() async {
|
||||
logger.fine('ensurePublisherConnected()');
|
||||
if (!_subscriberPrimary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (publisher?.pc.iceConnectionState?.isConnected() == true) {
|
||||
logger.warning('[$objectId] publisher is already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// start negotiation
|
||||
await negotiate();
|
||||
|
||||
logger.fine('[PUBLISHER] waiting for to ice-connect '
|
||||
'(current: ${publisher?.pc.iceConnectionState})');
|
||||
|
||||
await events.waitFor<EnginePublisherIceStateUpdatedEvent>(
|
||||
filter: (event) => event.iceState.isConnected(),
|
||||
duration: Timeouts.iceConnection,
|
||||
);
|
||||
|
||||
logger.fine('[PUBLISHER] connected');
|
||||
}
|
||||
|
||||
@internal
|
||||
Future<void> reconnect() async {
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
logger.fine('$objectId reconnect() already closed');
|
||||
return;
|
||||
}
|
||||
|
||||
final url = this.url;
|
||||
final token = this.token;
|
||||
|
||||
if (url == null || token == null) {
|
||||
throw ConnectException('could not reconnect without url and token');
|
||||
}
|
||||
|
||||
if (_reconnectAttempts == 0) {
|
||||
events.emit(const EngineReconnectingEvent());
|
||||
}
|
||||
_reconnectAttempts++;
|
||||
|
||||
try {
|
||||
// isReconnecting = true;
|
||||
_connectionState = ConnectionState.reconnecting;
|
||||
await signalClient.reconnect(
|
||||
url,
|
||||
token,
|
||||
connectOptions: room.connectOptions,
|
||||
);
|
||||
|
||||
if (publisher == null || subscriber == null) {
|
||||
throw UnexpectedStateException('publisher or subscribers is null');
|
||||
}
|
||||
|
||||
subscriber!.restartingIce = true;
|
||||
|
||||
// await negotiate(iceRestart: true);
|
||||
if (_hasPublished) {
|
||||
logger.fine('reconnect: publisher.createAndSendOffer');
|
||||
await publisher!
|
||||
.createAndSendOffer(const RTCOfferOptions(iceRestart: true));
|
||||
}
|
||||
|
||||
if (!(primary?.pc.iceConnectionState?.isConnected() ?? false)) {
|
||||
logger.fine('reconnect: waiting for primary to ice-connect...');
|
||||
|
||||
await events.waitFor<EngineIceStateUpdatedEvent>(
|
||||
filter: (event) => event.isPrimary && event.iceState.isConnected(),
|
||||
duration: Timeouts.iceRestart,
|
||||
);
|
||||
}
|
||||
|
||||
logger.fine('reconnect: success');
|
||||
events.emit(const EngineReconnectedEvent());
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
// don't catch and pass up any exception
|
||||
} finally {
|
||||
// always set reconnecting to false
|
||||
// isReconnecting = false;
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configurePeerConnections() async {
|
||||
if (publisher != null || subscriber != null) {
|
||||
logger.warning('Already configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// RTCConfiguration? config;
|
||||
// use server-provided iceServers if not provided by user
|
||||
final connectOptions = room.connectOptions ?? const ConnectOptions();
|
||||
final serverIceServers =
|
||||
_serverProvidedIceServers.map((e) => e.toSDKType()).toList();
|
||||
|
||||
RTCConfiguration rtcConfiguration = connectOptions.rtcConfiguration;
|
||||
if (serverIceServers.isNotEmpty) {
|
||||
// use server provided iceServers if exists
|
||||
rtcConfiguration = connectOptions.rtcConfiguration
|
||||
.copyWith(iceServers: serverIceServers);
|
||||
}
|
||||
|
||||
publisher = await PCTransport.create(rtcConfiguration);
|
||||
subscriber = await PCTransport.create(rtcConfiguration);
|
||||
|
||||
publisher?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) {
|
||||
logger.fine('publisher onIceCandidate');
|
||||
signalClient.sendIceCandidate(candidate, lk_rtc.SignalTarget.PUBLISHER);
|
||||
};
|
||||
|
||||
subscriber?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) {
|
||||
logger.fine('subscriber onIceCandidate');
|
||||
signalClient.sendIceCandidate(candidate, lk_rtc.SignalTarget.SUBSCRIBER);
|
||||
};
|
||||
|
||||
publisher?.onOffer = (offer) {
|
||||
logger.fine('publisher onOffer');
|
||||
signalClient.sendOffer(offer);
|
||||
};
|
||||
|
||||
// in subscriber primary mode, server side opens sub data channels.
|
||||
if (_subscriberPrimary) {
|
||||
subscriber?.pc.onDataChannel = _onDataChannel;
|
||||
}
|
||||
|
||||
subscriber?.pc.onIceConnectionState =
|
||||
(state) => events.emit(EngineSubscriberIceStateUpdatedEvent(
|
||||
state: state,
|
||||
isPrimary: _subscriberPrimary,
|
||||
));
|
||||
|
||||
publisher?.pc.onIceConnectionState =
|
||||
(state) => events.emit(EnginePublisherIceStateUpdatedEvent(
|
||||
state: state,
|
||||
isPrimary: !_subscriberPrimary,
|
||||
));
|
||||
|
||||
events.on<EngineIceStateUpdatedEvent>((event) {
|
||||
// only listen to primary ice events
|
||||
if (!event.isPrimary) return;
|
||||
|
||||
if (event.iceState ==
|
||||
rtc.RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
||||
if (!_iceConnected) {
|
||||
_iceConnected = true;
|
||||
if (_connectionState == ConnectionState.reconnecting) {
|
||||
events.emit(const EngineReconnectedEvent());
|
||||
} else {
|
||||
events.emit(const EngineConnectedEvent());
|
||||
}
|
||||
}
|
||||
} else if (event.iceState ==
|
||||
rtc.RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
||||
// trigger reconnect sequence
|
||||
if (_iceConnected) {
|
||||
_iceConnected = false;
|
||||
_onDisconnected('peerconnection');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
subscriber?.pc.onTrack = (rtc.RTCTrackEvent event) {
|
||||
logger.fine('[WebRTC] pc.onTrack');
|
||||
|
||||
final stream = event.streams.firstOrNull;
|
||||
if (stream == null) {
|
||||
// we need the stream to get the track's id
|
||||
logger.severe('received track without mediastream');
|
||||
return;
|
||||
}
|
||||
|
||||
// doesn't get called reliably
|
||||
event.track.onEnded = () {
|
||||
logger.fine('[WebRTC] track.onEnded');
|
||||
};
|
||||
|
||||
// doesn't get called reliably
|
||||
stream.onRemoveTrack = (_) {
|
||||
logger.fine('[WebRTC] stream.onRemoveTrack');
|
||||
};
|
||||
|
||||
events.emit(EngineTrackAddedEvent(
|
||||
track: event.track,
|
||||
stream: stream,
|
||||
receiver: event.receiver,
|
||||
));
|
||||
};
|
||||
|
||||
// doesn't get called reliably, doesn't work on mac
|
||||
subscriber?.pc.onRemoveTrack =
|
||||
(rtc.MediaStream stream, rtc.MediaStreamTrack track) {
|
||||
logger.fine('[WebRTC] ${track.id} pc.onRemoveTrack');
|
||||
};
|
||||
|
||||
// also handle messages over the pub channel, for backwards compatibility
|
||||
try {
|
||||
final lossyInit = rtc.RTCDataChannelInit()
|
||||
..binaryType = 'binary'
|
||||
..ordered = true
|
||||
..maxRetransmits = 0;
|
||||
_lossyDC =
|
||||
await publisher?.pc.createDataChannel(_lossyDCLabel, lossyInit);
|
||||
_lossyDC?.onMessage = _onDCMessage;
|
||||
_lossyDC?.stateChangeStream
|
||||
.listen((state) => _onDCStateUpdated(Reliability.lossy, state));
|
||||
} catch (_) {
|
||||
logger.severe('[$objectId] createDataChannel() did throw $_');
|
||||
}
|
||||
|
||||
try {
|
||||
final reliableInit = rtc.RTCDataChannelInit()
|
||||
..binaryType = 'binary'
|
||||
..ordered = true;
|
||||
_reliableDC =
|
||||
await publisher?.pc.createDataChannel(_reliableDCLabel, reliableInit);
|
||||
_reliableDC?.onMessage = _onDCMessage;
|
||||
_reliableDC?.stateChangeStream
|
||||
.listen((state) => _onDCStateUpdated(Reliability.reliable, state));
|
||||
} catch (_) {
|
||||
logger.severe('[$objectId] createDataChannel() did throw $_');
|
||||
}
|
||||
}
|
||||
|
||||
void _onDataChannel(rtc.RTCDataChannel dc) {
|
||||
switch (dc.label) {
|
||||
case _reliableDCLabel:
|
||||
logger.fine('Server opened DC label: ${dc.label}');
|
||||
_reliableDCSub = dc;
|
||||
_reliableDCSub?.onMessage = _onDCMessage;
|
||||
_reliableDCSub?.stateChangeStream
|
||||
.listen((state) => _onDCStateUpdated(Reliability.reliable, state));
|
||||
break;
|
||||
case _lossyDCLabel:
|
||||
logger.fine('Server opened DC label: ${dc.label}');
|
||||
_lossyDCSub = dc;
|
||||
_lossyDCSub?.onMessage = _onDCMessage;
|
||||
_lossyDCSub?.stateChangeStream
|
||||
.listen((event) => _onDCStateUpdated(Reliability.lossy, event));
|
||||
break;
|
||||
default:
|
||||
logger.warning('Unknown DC label: ${dc.label}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDCStateUpdated(
|
||||
Reliability channel,
|
||||
rtc.RTCDataChannelState state,
|
||||
) {
|
||||
logger.fine('Data channel state updated ${channel} ${state}');
|
||||
}
|
||||
|
||||
void _onDCMessage(rtc.RTCDataChannelMessage message) {
|
||||
// always expect binary
|
||||
if (!message.isBinary) {
|
||||
logger.warning('Data message is not binary');
|
||||
return;
|
||||
}
|
||||
|
||||
final dp = lk_models.DataPacket.fromBuffer(message.binary);
|
||||
if (dp.whichValue() == lk_models.DataPacket_Value.speaker) {
|
||||
// Speaker packet
|
||||
events
|
||||
.emit(EngineActiveSpeakersUpdateEvent(speakers: dp.speaker.speakers));
|
||||
} else if (dp.whichValue() == lk_models.DataPacket_Value.user) {
|
||||
// User packet
|
||||
events.emit(EngineDataPacketReceivedEvent(
|
||||
packet: dp.user,
|
||||
kind: dp.kind,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDisconnected(String reason) async {
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
logger.fine('[$objectId] Already disconnected $reason');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.fine('[$objectId] Disconnected $reason');
|
||||
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
logger.info('[$objectId] Could not connect '
|
||||
'after ${_reconnectAttempts} attempts, giving up');
|
||||
await close();
|
||||
events.emit(const EngineDisconnectedEvent());
|
||||
return;
|
||||
}
|
||||
|
||||
final delay =
|
||||
Duration(milliseconds: (_reconnectAttempts * _reconnectAttempts) * 300);
|
||||
|
||||
// if this instance is disposed, we probably don't want to continue any more
|
||||
// so the whole block will be canceled from being executed
|
||||
await delays.waitFor(delay, ifNotCancelled: () async {
|
||||
try {
|
||||
await reconnect();
|
||||
_reconnectAttempts = 0;
|
||||
} catch (_) {
|
||||
// doesn't need to be awaited
|
||||
// ignore: unawaited_futures
|
||||
_onDisconnected(reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _setUpListeners() => _signalListener
|
||||
..on<SignalConnectedEvent>((event) async {
|
||||
// create peer connections
|
||||
_connectionState = ConnectionState.connected;
|
||||
_subscriberPrimary = event.response.subscriberPrimary;
|
||||
_serverProvidedIceServers = event.response.iceServers;
|
||||
|
||||
logger.fine('onConnected subscriberPrimary: ${_subscriberPrimary}, '
|
||||
'serverVersion: ${event.response.serverVersion}, '
|
||||
'iceServers: ${event.response.iceServers}');
|
||||
|
||||
await _configurePeerConnections();
|
||||
|
||||
if (!_subscriberPrimary) {
|
||||
// for subscriberPrimary, we negotiate when necessary (lazy)
|
||||
await negotiate();
|
||||
}
|
||||
})
|
||||
..on<SignalCloseEvent>((_) async {
|
||||
await _onDisconnected('signal');
|
||||
})
|
||||
..on<SignalOfferEvent>((event) async {
|
||||
if (subscriber == null) {
|
||||
logger.warning('[$objectId] subscriber is null');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.fine('[$objectId] Received server offer(type: ${event.sd.type}, '
|
||||
'${subscriber!.pc.signalingState})');
|
||||
logger.finer('sdp: ${event.sd.sdp}');
|
||||
|
||||
await subscriber!.setRemoteDescription(event.sd);
|
||||
|
||||
try {
|
||||
final answer = await subscriber!.pc.createAnswer();
|
||||
logger.fine('Created answer');
|
||||
logger.finer('sdp: ${answer.sdp}');
|
||||
await subscriber!.pc.setLocalDescription(answer);
|
||||
signalClient.sendAnswer(answer);
|
||||
} catch (_) {
|
||||
logger.severe('[$objectId] Failed to createAnswer()');
|
||||
}
|
||||
})
|
||||
..on<SignalAnswerEvent>((event) async {
|
||||
if (publisher == null) {
|
||||
return;
|
||||
}
|
||||
logger.fine('received answer (type: ${event.sd.type})');
|
||||
logger.finer('sdp: ${event.sd.sdp}');
|
||||
await publisher!.setRemoteDescription(event.sd);
|
||||
})
|
||||
..on<SignalTrickleEvent>((event) async {
|
||||
if (publisher == null || subscriber == null) {
|
||||
logger.warning(
|
||||
'Received ${SignalTrickleEvent} but publisher or subscriber was null.');
|
||||
return;
|
||||
}
|
||||
logger.fine('got ICE candidate from peer');
|
||||
if (event.target == lk_rtc.SignalTarget.SUBSCRIBER) {
|
||||
await subscriber!.addIceCandidate(event.candidate);
|
||||
} else if (event.target == lk_rtc.SignalTarget.PUBLISHER) {
|
||||
await publisher!.addIceCandidate(event.candidate);
|
||||
}
|
||||
})
|
||||
// relay
|
||||
..on<SignalParticipantUpdateEvent>((event) => events.emit(event))
|
||||
// relay
|
||||
..on<SignalSpeakersChangedEvent>((event) => events.emit(event))
|
||||
// relay
|
||||
..on<SignalConnectionQualityUpdateEvent>((event) => events.emit(event))
|
||||
// relay
|
||||
..on<SignalStreamStateUpdatedEvent>((event) => events.emit(event))
|
||||
..on<SignalLeaveEvent>((event) async {
|
||||
await close();
|
||||
events.emit(const EngineDisconnectedEvent());
|
||||
})
|
||||
..on<SignalMuteTrackEvent>(
|
||||
(event) => events.emit(EngineRemoteMuteChangedEvent(
|
||||
sid: event.sid,
|
||||
muted: event.muted,
|
||||
)));
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../events.dart';
|
||||
import '../exceptions.dart';
|
||||
import '../extensions.dart';
|
||||
import '../internal/events.dart';
|
||||
import '../logger.dart';
|
||||
import '../managers/event.dart';
|
||||
import '../options.dart';
|
||||
import '../participant/local.dart';
|
||||
import '../participant/participant.dart';
|
||||
import '../participant/remote.dart';
|
||||
import '../proto/livekit_models.pb.dart' as lk_models;
|
||||
import '../proto/livekit_rtc.pb.dart' as lk_rtc;
|
||||
import '../support/disposable.dart';
|
||||
import '../track/track.dart';
|
||||
import '../types.dart';
|
||||
import 'engine.dart';
|
||||
|
||||
/// Room is the primary construct for LiveKit conferences. It contains a
|
||||
/// group of [Participant]s, each publishing and subscribing to [Track]s.
|
||||
/// Notifies changes to its state via two ways, by assigning a delegate, or using
|
||||
/// it as a provider.
|
||||
/// Room will trigger a change notification update when
|
||||
/// * state changes
|
||||
/// * participant membership changes
|
||||
/// * active speakers are different
|
||||
/// {@category Room}
|
||||
class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
|
||||
// Room is only instantiated if connected, so defaults to connected.
|
||||
ConnectionState _connectionState = ConnectionState.connected;
|
||||
|
||||
/// connection state of the room
|
||||
ConnectionState get connectionState => _connectionState;
|
||||
|
||||
final _participants = <String, RemoteParticipant>{};
|
||||
|
||||
/// map of SID to RemoteParticipant
|
||||
UnmodifiableMapView<String, RemoteParticipant> get participants =>
|
||||
UnmodifiableMapView(_participants);
|
||||
|
||||
ConnectOptions? connectOptions;
|
||||
|
||||
RoomOptions? roomOptions;
|
||||
|
||||
/// the current participant
|
||||
LocalParticipant? localParticipant;
|
||||
|
||||
/// name of the room
|
||||
String? name;
|
||||
|
||||
/// sid of the room
|
||||
String? sid;
|
||||
|
||||
List<Participant> _activeSpeakers = [];
|
||||
|
||||
/// a list of participants that are actively speaking, including local participant.
|
||||
UnmodifiableListView<Participant> get activeSpeakers =>
|
||||
UnmodifiableListView<Participant>(_activeSpeakers);
|
||||
|
||||
late final engine = Engine(room: this);
|
||||
|
||||
// suppport for multiple event listeners
|
||||
late final _engineListener = engine.createListener();
|
||||
|
||||
Room({
|
||||
this.connectOptions,
|
||||
this.roomOptions,
|
||||
}) {
|
||||
//
|
||||
_setUpListeners();
|
||||
|
||||
// Any event emitted will trigger ChangeNotifier
|
||||
events.listen((event) {
|
||||
logger.fine('[RoomEvent] $event, will notifyListeners()');
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
onDispose(() async {
|
||||
// dispose events
|
||||
await events.dispose();
|
||||
// dispose local participant
|
||||
await localParticipant?.dispose();
|
||||
// dispose all listeners for RTCEngine
|
||||
await _engineListener.dispose();
|
||||
// dispose the engine
|
||||
await engine.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> connect(
|
||||
String url,
|
||||
String token, {
|
||||
ConnectOptions? connectOptions,
|
||||
RoomOptions? roomOptions,
|
||||
}) async {
|
||||
// update options if provided
|
||||
this.connectOptions = connectOptions ?? this.connectOptions;
|
||||
this.roomOptions = roomOptions ?? this.roomOptions;
|
||||
|
||||
final joinResponse = await engine.connect(
|
||||
url,
|
||||
token,
|
||||
);
|
||||
|
||||
sid = joinResponse.room.sid;
|
||||
name = joinResponse.room.name;
|
||||
|
||||
logger.fine(
|
||||
'Connected to LiveKit server, version: ${joinResponse.serverVersion}');
|
||||
|
||||
localParticipant = LocalParticipant(
|
||||
room: this,
|
||||
info: joinResponse.participant,
|
||||
);
|
||||
|
||||
for (final info in joinResponse.otherParticipants) {
|
||||
logger.fine('Creating RemoteParticipant: ${info.sid}(${info.identity}) '
|
||||
'tracks:${info.tracks.map((e) => e.sid)}');
|
||||
_getOrCreateRemoteParticipant(info.sid, info);
|
||||
}
|
||||
|
||||
logger.fine('Waiting to engine connect...');
|
||||
|
||||
// wait until engine is connected
|
||||
await _engineListener.waitFor<EngineConnectedEvent>(
|
||||
duration: Timeouts.connection,
|
||||
onTimeout: () => throw ConnectException(),
|
||||
);
|
||||
|
||||
logger.fine('Room Connect completed');
|
||||
}
|
||||
|
||||
void _setUpListeners() => _engineListener
|
||||
..on<EngineConnectedEvent>((event) async {
|
||||
_connectionState = ConnectionState.connected;
|
||||
notifyListeners();
|
||||
})
|
||||
..on<EngineReconnectedEvent>((event) async {
|
||||
_connectionState = ConnectionState.connected;
|
||||
events.emit(const RoomReconnectedEvent());
|
||||
})
|
||||
..on<EngineReconnectingEvent>((event) async {
|
||||
_connectionState = ConnectionState.reconnecting;
|
||||
events.emit(const RoomReconnectingEvent());
|
||||
})
|
||||
..on<EngineDisconnectedEvent>((event) => _handleClose())
|
||||
..on<SignalParticipantUpdateEvent>(
|
||||
(event) => _onParticipantUpdateEvent(event.participants))
|
||||
..on<EngineActiveSpeakersUpdateEvent>(
|
||||
(event) => _onEngineActiveSpeakersUpdateEvent(event.speakers))
|
||||
..on<SignalSpeakersChangedEvent>(
|
||||
(event) => _onSignalSpeakersChangedEvent(event.speakers))
|
||||
..on<SignalConnectionQualityUpdateEvent>(
|
||||
(event) => _onSignalConnectionQualityUpdateEvent(event.updates))
|
||||
..on<SignalStreamStateUpdatedEvent>(
|
||||
(event) => _onSignalStreamStateUpdateEvent(event.updates))
|
||||
..on<EngineDataPacketReceivedEvent>(_onDataMessageEvent)
|
||||
..on<EngineRemoteMuteChangedEvent>((event) async {
|
||||
final publication = localParticipant?.trackPublications[event.sid];
|
||||
if (event.muted) {
|
||||
await publication?.mute();
|
||||
} else {
|
||||
await publication?.unmute();
|
||||
}
|
||||
})
|
||||
..on<EngineTrackAddedEvent>((event) async {
|
||||
logger.fine('EngineTrackAddedEvent trackSid:${event.track.id}');
|
||||
|
||||
final idParts = event.stream.id.split('|');
|
||||
final participantSid = idParts[0];
|
||||
final trackSid = idParts.elementAtOrNull(1) ?? event.track.id;
|
||||
final participant = _getOrCreateRemoteParticipant(participantSid, null);
|
||||
try {
|
||||
if (trackSid == null || trackSid.isEmpty) {
|
||||
throw TrackSubscriptionExceptionEvent(
|
||||
participant: participant,
|
||||
reason: TrackSubscribeFailReason.invalidServerResponse,
|
||||
);
|
||||
}
|
||||
await participant.addSubscribedMediaTrack(
|
||||
event.track,
|
||||
event.stream,
|
||||
trackSid,
|
||||
);
|
||||
} on TrackSubscriptionExceptionEvent catch (event) {
|
||||
logger.severe('addSubscribedMediaTrack() throwed ${event}');
|
||||
[participant.room.events, participant.events].emit(event);
|
||||
} catch (exception) {
|
||||
// We don't want to pass up any exception so catch everything here.
|
||||
logger.warning(
|
||||
'Unknown exception on addSubscribedMediaTrack() ${exception}');
|
||||
}
|
||||
});
|
||||
|
||||
/// Disconnects from the room, notifying server of disconnection.
|
||||
Future<void> disconnect() async {
|
||||
if (_connectionState != ConnectionState.disconnected) {
|
||||
engine.signalClient.sendLeave();
|
||||
}
|
||||
await _handleClose();
|
||||
}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await engine.reconnect();
|
||||
}
|
||||
|
||||
RemoteParticipant _getOrCreateRemoteParticipant(
|
||||
String sid, lk_models.ParticipantInfo? info) {
|
||||
RemoteParticipant? participant = _participants[sid];
|
||||
if (participant != null) {
|
||||
return participant;
|
||||
}
|
||||
|
||||
if (info == null) {
|
||||
logger.warning('RemoteParticipant.info is null trackSid: $sid');
|
||||
participant = RemoteParticipant(
|
||||
room: this,
|
||||
sid: sid,
|
||||
identity: '',
|
||||
);
|
||||
} else {
|
||||
participant = RemoteParticipant.fromInfo(
|
||||
room: this,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
_participants[sid] = participant;
|
||||
|
||||
return participant;
|
||||
}
|
||||
|
||||
// there should be no problem calling this method multiple times
|
||||
Future<void> _handleClose() async {
|
||||
logger.fine('[$objectId] _handleClose()');
|
||||
if (_connectionState == ConnectionState.disconnected) {
|
||||
logger.warning('[$objectId]: close() already disconnected');
|
||||
}
|
||||
|
||||
// clean up RemoteParticipants
|
||||
for (final _ in _participants.values.toList()) {
|
||||
// RemoteParticipant is responsible for disposing resources
|
||||
await _.dispose();
|
||||
}
|
||||
_participants.clear();
|
||||
|
||||
// clean up LocalParticipant
|
||||
await localParticipant?.unpublishAllTracks();
|
||||
|
||||
// clean up engine
|
||||
await engine.close();
|
||||
|
||||
_activeSpeakers.clear();
|
||||
|
||||
// only notify if was not disconnected
|
||||
if (_connectionState != ConnectionState.disconnected) {
|
||||
_connectionState = ConnectionState.disconnected;
|
||||
events.emit(const RoomDisconnectedEvent());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onParticipantUpdateEvent(
|
||||
List<lk_models.ParticipantInfo> updates) async {
|
||||
// trigger change notifier only if list of participants membership is changed
|
||||
var hasChanged = false;
|
||||
for (final info in updates) {
|
||||
if (localParticipant?.sid == info.sid) {
|
||||
localParticipant?.updateFromInfo(info);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.state == lk_models.ParticipantInfo_State.DISCONNECTED) {
|
||||
hasChanged = true;
|
||||
await _handleParticipantDisconnect(info.sid);
|
||||
continue;
|
||||
}
|
||||
|
||||
final isNew = !_participants.containsKey(info.sid);
|
||||
final participant = _getOrCreateRemoteParticipant(info.sid, info);
|
||||
|
||||
if (isNew) {
|
||||
hasChanged = true;
|
||||
events.emit(ParticipantConnectedEvent(participant: participant));
|
||||
} else {
|
||||
await participant.updateFromInfo(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSignalSpeakersChangedEvent(List<lk_models.SpeakerInfo> speakers) {
|
||||
//
|
||||
final lastSpeakers = {
|
||||
for (final p in _activeSpeakers) p.sid: p,
|
||||
};
|
||||
|
||||
for (final speaker in speakers) {
|
||||
Participant? p = _participants[speaker.sid];
|
||||
if (speaker.sid == localParticipant?.sid) p = localParticipant;
|
||||
if (p == null) continue;
|
||||
|
||||
p.audioLevel = speaker.level;
|
||||
p.isSpeaking = speaker.active;
|
||||
if (speaker.active) {
|
||||
lastSpeakers[speaker.sid] = p;
|
||||
} else {
|
||||
lastSpeakers.remove(speaker.sid);
|
||||
}
|
||||
}
|
||||
|
||||
final activeSpeakers = lastSpeakers.values.toList();
|
||||
activeSpeakers.sort((a, b) => b.audioLevel.compareTo(a.audioLevel));
|
||||
_activeSpeakers = activeSpeakers;
|
||||
events.emit(ActiveSpeakersChangedEvent(speakers: activeSpeakers));
|
||||
}
|
||||
|
||||
// from data channel
|
||||
// updates are sent only when there's a change to speaker ordering
|
||||
void _onEngineActiveSpeakersUpdateEvent(
|
||||
List<lk_models.SpeakerInfo> speakers) {
|
||||
List<Participant> activeSpeakers = [];
|
||||
|
||||
// localParticipant & remote participants
|
||||
final allParticipants = <String, Participant>{
|
||||
if (localParticipant != null) localParticipant!.sid: localParticipant!,
|
||||
..._participants,
|
||||
};
|
||||
|
||||
for (final speaker in speakers) {
|
||||
final p = allParticipants[speaker.sid];
|
||||
if (p != null) {
|
||||
p.audioLevel = speaker.level;
|
||||
p.isSpeaking = true;
|
||||
activeSpeakers.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
// clear if not in the speakers list
|
||||
final speakerSids = speakers.map((e) => e.sid).toSet();
|
||||
for (final p in allParticipants.values) {
|
||||
if (!speakerSids.contains(p.sid)) {
|
||||
p.audioLevel = 0;
|
||||
p.isSpeaking = false;
|
||||
}
|
||||
}
|
||||
|
||||
_activeSpeakers = activeSpeakers;
|
||||
events.emit(ActiveSpeakersChangedEvent(speakers: activeSpeakers));
|
||||
}
|
||||
|
||||
void _onSignalConnectionQualityUpdateEvent(
|
||||
List<lk_rtc.ConnectionQualityInfo> updates) {
|
||||
for (final entry in updates) {
|
||||
Participant? participant;
|
||||
if (entry.participantSid == localParticipant?.sid) {
|
||||
participant = localParticipant;
|
||||
} else {
|
||||
participant = _participants[entry.participantSid];
|
||||
}
|
||||
|
||||
if (participant != null) {
|
||||
// update the connection quality if the participant is found
|
||||
participant.updateConnectionQuality(entry.quality.toLKType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSignalStreamStateUpdateEvent(
|
||||
List<lk_rtc.StreamStateInfo> updates) async {
|
||||
for (final update in updates) {
|
||||
// try to find RemoteParticipant
|
||||
final participant = participants[update.participantSid];
|
||||
if (participant == null) continue;
|
||||
// try to find RemoteTrackPublication
|
||||
final trackPublication = participant.trackPublications[update.trackSid];
|
||||
if (trackPublication == null) continue;
|
||||
// update the stream state
|
||||
await trackPublication.updateStreamState(update.state.toLKType());
|
||||
}
|
||||
}
|
||||
|
||||
void _onDataMessageEvent(EngineDataPacketReceivedEvent dataPacketEvent) {
|
||||
// participant may be null if data is sent from Server-API
|
||||
final senderSid = dataPacketEvent.packet.participantSid;
|
||||
RemoteParticipant? senderParticipant;
|
||||
if (senderSid.isNotEmpty) {
|
||||
senderParticipant = participants[dataPacketEvent.packet.participantSid];
|
||||
}
|
||||
|
||||
// participant.delegate?.onDataReceived(participant, event.packet.payload);
|
||||
|
||||
final event = DataReceivedEvent(
|
||||
participant: senderParticipant,
|
||||
data: dataPacketEvent.packet.payload,
|
||||
);
|
||||
|
||||
senderParticipant?.events.emit(event);
|
||||
events.emit(event);
|
||||
}
|
||||
|
||||
Future<void> _handleParticipantDisconnect(String sid) async {
|
||||
final participant = _participants.remove(sid);
|
||||
if (participant == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await participant.unpublishAllTracks(notify: true);
|
||||
|
||||
events.emit(ParticipantDisconnectedEvent(participant: participant));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../events.dart';
|
||||
import '../exceptions.dart';
|
||||
import '../extensions.dart';
|
||||
import '../internal/events.dart';
|
||||
import '../logger.dart';
|
||||
import '../managers/event.dart';
|
||||
import '../options.dart';
|
||||
import '../proto/livekit_models.pb.dart' as lk_models;
|
||||
import '../proto/livekit_rtc.pb.dart' as lk_rtc;
|
||||
import '../support/disposable.dart';
|
||||
import '../support/websocket.dart';
|
||||
import '../types.dart';
|
||||
import '../utils.dart';
|
||||
|
||||
class SignalClient extends Disposable with EventsEmittable<SignalEvent> {
|
||||
//
|
||||
bool _connected = false;
|
||||
LiveKitWebSocket? _ws;
|
||||
|
||||
SignalClient() {
|
||||
events.listen((event) {
|
||||
logger.fine('[SignalEvent] $event');
|
||||
});
|
||||
|
||||
onDispose(() async {
|
||||
await events.dispose();
|
||||
await close();
|
||||
});
|
||||
}
|
||||
|
||||
bool get connected => _connected;
|
||||
|
||||
Future<void> connect(
|
||||
String uriString,
|
||||
String token, {
|
||||
ConnectOptions? connectOptions,
|
||||
}) async {
|
||||
final rtcUri = Utils.buildUri(
|
||||
uriString,
|
||||
token: token,
|
||||
connectOptions: connectOptions,
|
||||
);
|
||||
|
||||
try {
|
||||
_ws = await LiveKitWebSocket.connect(
|
||||
rtcUri,
|
||||
WebSocketEventHandlers(
|
||||
onData: _onSocketData,
|
||||
onDispose: _onSocketDone,
|
||||
onError: _handleError,
|
||||
),
|
||||
);
|
||||
} catch (socketError) {
|
||||
// Re-build same uri for validate mode
|
||||
final validateUri = Utils.buildUri(
|
||||
uriString,
|
||||
token: token,
|
||||
connectOptions: connectOptions,
|
||||
validate: true,
|
||||
forceSecure: rtcUri.isSecureScheme,
|
||||
);
|
||||
|
||||
// Attempt Validation
|
||||
try {
|
||||
final validateResponse = await http.get(validateUri);
|
||||
if (validateResponse.statusCode != 200) {
|
||||
throw ConnectException(validateResponse.body);
|
||||
}
|
||||
throw ConnectException();
|
||||
} catch (error) {
|
||||
// Pass it up if it's already a `ConnectError`
|
||||
if (error is ConnectException) rethrow;
|
||||
// HTTP doesn't work either
|
||||
throw ConnectException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reconnect(
|
||||
String uriString,
|
||||
String token, {
|
||||
ConnectOptions? connectOptions,
|
||||
}) async {
|
||||
_connected = false;
|
||||
await _ws?.dispose();
|
||||
_ws = null;
|
||||
|
||||
final rtcUri = Utils.buildUri(
|
||||
uriString,
|
||||
token: token,
|
||||
reconnect: true,
|
||||
connectOptions: connectOptions,
|
||||
);
|
||||
|
||||
_ws = await LiveKitWebSocket.connect(
|
||||
rtcUri,
|
||||
WebSocketEventHandlers(
|
||||
onData: _onSocketData,
|
||||
onDispose: _onSocketDone,
|
||||
onError: _handleError,
|
||||
),
|
||||
);
|
||||
|
||||
_connected = true;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
_connected = false;
|
||||
await _ws?.dispose();
|
||||
}
|
||||
|
||||
void sendOffer(rtc.RTCSessionDescription offer) =>
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
offer: offer.toSDKType(),
|
||||
));
|
||||
|
||||
void sendAnswer(rtc.RTCSessionDescription answer) =>
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
answer: answer.toSDKType(),
|
||||
));
|
||||
|
||||
void sendIceCandidate(
|
||||
rtc.RTCIceCandidate candidate, lk_rtc.SignalTarget target) =>
|
||||
_sendRequest(
|
||||
lk_rtc.SignalRequest(
|
||||
trickle: lk_rtc.TrickleRequest(
|
||||
candidateInit: candidate.toJson(),
|
||||
target: target,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void sendMuteTrack(String trackSid, bool muted) =>
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
mute: lk_rtc.MuteTrackRequest(
|
||||
sid: trackSid,
|
||||
muted: muted,
|
||||
),
|
||||
));
|
||||
|
||||
void sendAddTrack({
|
||||
required String cid,
|
||||
required String name,
|
||||
required lk_models.TrackType type,
|
||||
required lk_models.TrackSource source,
|
||||
VideoDimensions? dimensions,
|
||||
bool? dtx,
|
||||
List<lk_models.VideoLayer>? videoLayers,
|
||||
}) {
|
||||
final req = lk_rtc.AddTrackRequest(
|
||||
cid: cid,
|
||||
name: name,
|
||||
type: type,
|
||||
source: source,
|
||||
);
|
||||
|
||||
if (type == lk_models.TrackType.VIDEO) {
|
||||
// video specific
|
||||
if (dimensions != null) {
|
||||
req.width = dimensions.width;
|
||||
req.height = dimensions.height;
|
||||
}
|
||||
if (videoLayers != null && videoLayers.isNotEmpty) {
|
||||
req.layers
|
||||
..clear()
|
||||
..addAll(videoLayers);
|
||||
}
|
||||
}
|
||||
|
||||
if (type == lk_models.TrackType.AUDIO && dtx != null) {
|
||||
// audio specific
|
||||
req.disableDtx = !dtx;
|
||||
}
|
||||
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
addTrack: req,
|
||||
));
|
||||
}
|
||||
|
||||
void sendUpdateTrackSettings(lk_rtc.UpdateTrackSettings settings) =>
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
trackSetting: settings,
|
||||
));
|
||||
|
||||
void sendUpdateSubscription(lk_rtc.UpdateSubscription subscription) =>
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
subscription: subscription,
|
||||
));
|
||||
|
||||
void sendUpdateVideoLayers(
|
||||
String trackSid,
|
||||
List<lk_models.VideoLayer> layers,
|
||||
) =>
|
||||
_sendRequest(lk_rtc.SignalRequest(
|
||||
updateLayers: lk_rtc.UpdateVideoLayers(
|
||||
trackSid: trackSid,
|
||||
layers: layers,
|
||||
),
|
||||
));
|
||||
|
||||
void sendLeave() => _sendRequest(lk_rtc.SignalRequest(
|
||||
leave: lk_rtc.LeaveRequest(),
|
||||
));
|
||||
|
||||
void _sendRequest(lk_rtc.SignalRequest req) {
|
||||
if (_ws == null || isDisposed) {
|
||||
logger.warning(
|
||||
'[$objectId] Could not send message, not connected or already disposed');
|
||||
return;
|
||||
}
|
||||
|
||||
final buf = req.writeToBuffer();
|
||||
_ws?.send(buf);
|
||||
}
|
||||
|
||||
Future<void> _onSocketData(dynamic message) async {
|
||||
if (message is! List<int>) return;
|
||||
final msg = lk_rtc.SignalResponse.fromBuffer(message);
|
||||
|
||||
switch (msg.whichMessage()) {
|
||||
case lk_rtc.SignalResponse_Message.join:
|
||||
if (!_connected) {
|
||||
_connected = true;
|
||||
events.emit(SignalConnectedEvent(response: msg.join));
|
||||
}
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.answer:
|
||||
events.emit(SignalAnswerEvent(sd: msg.answer.toSDKType()));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.offer:
|
||||
events.emit(SignalOfferEvent(sd: msg.offer.toSDKType()));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.trickle:
|
||||
events.emit(SignalTrickleEvent(
|
||||
candidate: RTCIceCandidateExt.fromJson(msg.trickle.candidateInit),
|
||||
target: msg.trickle.target,
|
||||
));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.update:
|
||||
events.emit(SignalParticipantUpdateEvent(
|
||||
participants: msg.update.participants));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.trackPublished:
|
||||
events.emit(SignalLocalTrackPublishedEvent(
|
||||
cid: msg.trackPublished.cid,
|
||||
track: msg.trackPublished.track,
|
||||
));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.speakersChanged:
|
||||
events.emit(
|
||||
SignalSpeakersChangedEvent(speakers: msg.speakersChanged.speakers));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.connectionQuality:
|
||||
events.emit(SignalConnectionQualityUpdateEvent(
|
||||
updates: msg.connectionQuality.updates,
|
||||
));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.leave:
|
||||
events.emit(SignalLeaveEvent(canReconnect: msg.leave.canReconnect));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.mute:
|
||||
events.emit(SignalMuteTrackEvent(
|
||||
sid: msg.mute.sid,
|
||||
muted: msg.mute.muted,
|
||||
));
|
||||
break;
|
||||
case lk_rtc.SignalResponse_Message.streamStateUpdate:
|
||||
events.emit(SignalStreamStateUpdatedEvent(
|
||||
updates: msg.streamStateUpdate.streamStates,
|
||||
));
|
||||
break;
|
||||
default:
|
||||
logger.warning('skipping unsupported signal message');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleError(dynamic error) {
|
||||
logger.warning('received websocket error $error');
|
||||
}
|
||||
|
||||
void _onSocketDone() {
|
||||
if (!_connected) return;
|
||||
_ws = null;
|
||||
_connected = false;
|
||||
events.emit(const SignalCloseEvent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
|
||||
import '../constants.dart';
|
||||
import '../extensions.dart';
|
||||
import '../logger.dart';
|
||||
import '../support/disposable.dart';
|
||||
import '../types.dart';
|
||||
import '../utils.dart';
|
||||
|
||||
typedef PCTransportOnOffer = void Function(rtc.RTCSessionDescription offer);
|
||||
|
||||
/// a wrapper around PeerConnection
|
||||
class PCTransport extends Disposable {
|
||||
final rtc.RTCPeerConnection pc;
|
||||
final List<rtc.RTCIceCandidate> _pendingCandidates = [];
|
||||
bool restartingIce = false;
|
||||
bool renegotiate = false;
|
||||
PCTransportOnOffer? onOffer;
|
||||
Function? _cancelDebounce;
|
||||
|
||||
// private constructor
|
||||
PCTransport._(this.pc) {
|
||||
//
|
||||
onDispose(() async {
|
||||
_cancelDebounce?.call();
|
||||
_cancelDebounce = null;
|
||||
|
||||
// Ensure callbacks won't fire any more
|
||||
pc.onRenegotiationNeeded = null;
|
||||
pc.onIceCandidate = null;
|
||||
pc.onIceConnectionState = null;
|
||||
pc.onTrack = null;
|
||||
|
||||
// Remove all senders
|
||||
List<rtc.RTCRtpSender> senders = [];
|
||||
try {
|
||||
senders = await pc.getSenders();
|
||||
} catch (_) {
|
||||
logger.warning('getSenders() failed with error: $_');
|
||||
}
|
||||
|
||||
for (final e in senders) {
|
||||
try {
|
||||
await pc.removeTrack(e);
|
||||
} catch (_) {
|
||||
logger.warning('removeTrack() failed with error: $_');
|
||||
}
|
||||
}
|
||||
|
||||
await pc.close();
|
||||
await pc.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
static Future<PCTransport> create([RTCConfiguration? rtcConfig]) async {
|
||||
rtcConfig ??= const RTCConfiguration();
|
||||
logger.fine('[PCTransport] creating ${rtcConfig.toMap()}');
|
||||
final _ = await rtc.createPeerConnection(rtcConfig.toMap());
|
||||
return PCTransport._(_);
|
||||
}
|
||||
|
||||
late final negotiate = Utils.createDebounceFunc(
|
||||
(void _) => createAndSendOffer(),
|
||||
cancelFunc: (f) => _cancelDebounce = f,
|
||||
wait: Timeouts.debounce,
|
||||
);
|
||||
|
||||
Future<void> setRemoteDescription(rtc.RTCSessionDescription sd) async {
|
||||
if (isDisposed) {
|
||||
logger.warning('[$objectId] setRemoteDescription() already disposed');
|
||||
return;
|
||||
}
|
||||
|
||||
await pc.setRemoteDescription(sd);
|
||||
|
||||
for (final candidate in _pendingCandidates) {
|
||||
await pc.addCandidate(candidate);
|
||||
}
|
||||
|
||||
_pendingCandidates.clear();
|
||||
restartingIce = false;
|
||||
|
||||
if (renegotiate) {
|
||||
renegotiate = false;
|
||||
await createAndSendOffer(); // await or un-awaited ?
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createAndSendOffer([RTCOfferOptions? options]) async {
|
||||
if (isDisposed) {
|
||||
logger.warning('[$objectId] createAndSendOffer() already disposed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOffer == null) {
|
||||
logger.warning('onOffer is null');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.iceRestart ?? false) {
|
||||
logger.fine('restarting ICE');
|
||||
restartingIce = true;
|
||||
}
|
||||
|
||||
if (pc.signalingState ==
|
||||
rtc.RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
|
||||
// we're waiting for the peer to accept our offer, so we'll just wait
|
||||
// the only exception to this is when ICE restart is needed
|
||||
final currentSD = await getRemoteDescription();
|
||||
if ((options?.iceRestart ?? false) && currentSD != null) {
|
||||
// TODO: handle when ICE restart is needed but we don't have a remote description
|
||||
// the best thing to do is to recreate the peerconnection
|
||||
await pc.setRemoteDescription(currentSD);
|
||||
} else {
|
||||
renegotiate = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// actually negotiate
|
||||
logger.fine('starting to negotiate');
|
||||
final offer = await pc.createOffer(options?.toMap() ?? <String, dynamic>{});
|
||||
await pc.setLocalDescription(offer);
|
||||
onOffer?.call(offer);
|
||||
}
|
||||
|
||||
Future<void> addIceCandidate(rtc.RTCIceCandidate candidate) async {
|
||||
if (isDisposed) {
|
||||
logger.warning('[$objectId] addIceCandidate() already disposed');
|
||||
return;
|
||||
}
|
||||
|
||||
final desc = await getRemoteDescription();
|
||||
|
||||
if (desc != null && !restartingIce) {
|
||||
await pc.addCandidate(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingCandidates.add(candidate);
|
||||
}
|
||||
|
||||
Future<rtc.RTCSessionDescription?> getRemoteDescription() async {
|
||||
if (isDisposed) {
|
||||
logger.warning('[$objectId] getRemoteDescription() already disposed');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Checking agains null doesn't work as intended
|
||||
// if (pc.iceConnectionState == null) return null;
|
||||
|
||||
try {
|
||||
final result = await pc.getRemoteDescription();
|
||||
logger.fine('pc.getRemoteDescription $result');
|
||||
return result;
|
||||
} catch (_) {
|
||||
logger.warning('pc.getRemoteDescription failed with error: $_');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user