connectivity handling, audio publishing

This commit is contained in:
David Zhao
2021-08-04 10:59:41 -07:00
parent aec2003140
commit 593890331e
14 changed files with 208 additions and 33 deletions
+5
View File
@@ -0,0 +1,5 @@
import 'package:web_socket_channel/web_socket_channel.dart';
Future<WebSocketChannel> connectToWebSocket(Uri uri) {
throw UnsupportedError('no implementations found');
}
+18
View File
@@ -0,0 +1,18 @@
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html';
import 'package:web_socket_channel/html.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
Future<WebSocketChannel> connectToWebSocket(Uri uri) {
var ws = WebSocket(uri.toString());
var completer = Completer<WebSocketChannel>();
ws.onOpen.first.then((_) {
completer.complete(HtmlWebSocketChannel(ws));
});
ws.onError.first.then((e) {
completer.completeError('could not connect');
});
return completer.future;
}
+14
View File
@@ -0,0 +1,14 @@
import 'dart:io';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
Future<WebSocketChannel> connectToWebSocket(Uri uri) async {
try {
// ignore: close_sinks
var ws = await WebSocket.connect(uri.toString());
return IOWebSocketChannel(ws);
} catch (e) {
return Future.error(e);
}
}
+5
View File
@@ -13,6 +13,11 @@ class ConnectError extends LiveKitError {
ConnectError([String msg = 'Failed to connect to server']) : super(msg); ConnectError([String msg = 'Failed to connect to server']) : super(msg);
} }
class UnexpectedConnectionState extends LiveKitError {
UnexpectedConnectionState([String msg = 'Unexpected connection state'])
: super(msg);
}
class TrackCreateError extends LiveKitError { class TrackCreateError extends LiveKitError {
TrackCreateError([String msg = 'Failed to create track']) : super(msg); TrackCreateError([String msg = 'Failed to create track']) : super(msg);
} }
@@ -71,6 +71,9 @@ class LocalParticipant extends Participant {
var trackInfo = await _engine.addTrack( var trackInfo = await _engine.addTrack(
cid: track.getCid(), name: track.name, kind: track.kind); cid: track.getCid(), name: track.name, kind: track.kind);
var stream = await getMediaStream(); var stream = await getMediaStream();
if (stream == null) {
return Future.error(TrackPublishError());
}
var transceiverInit = new RTCRtpTransceiverInit( var transceiverInit = new RTCRtpTransceiverInit(
direction: TransceiverDirection.SendOnly, direction: TransceiverDirection.SendOnly,
streams: [stream], streams: [stream],
+15 -3
View File
@@ -100,8 +100,16 @@ class Room extends ChangeNotifier with ParticipantDelegate {
_engine.onParticipantUpdateCallback = _handleParticipantUpdate; _engine.onParticipantUpdateCallback = _handleParticipantUpdate;
_engine.onActiveSpeakerchangedCallback = _handleSpeakerUpdate; _engine.onActiveSpeakerchangedCallback = _handleSpeakerUpdate;
_engine.onDataMessageCallback = _handleDataPacket; _engine.onDataMessageCallback = _handleDataPacket;
_engine.onReconnected = () {
// TODO: handle reconnecting & reconnected events _state = RoomState.Connected;
delegate?.onReconnected();
notifyListeners();
};
_engine.onReconnecting = () {
_state = RoomState.Reconnecting;
delegate?.onReconnecting();
notifyListeners();
};
} }
Future<Room> connect(String url, String token, [JoinOptions? opts]) async { Future<Room> connect(String url, String token, [JoinOptions? opts]) async {
@@ -128,6 +136,9 @@ class Room extends ChangeNotifier with ParticipantDelegate {
// room is not ready until ICE is connected. so we would return a completer for now // room is not ready until ICE is connected. so we would return a completer for now
// if it times out, we'll fail the completer // if it times out, we'll fail the completer
Timer(Duration(seconds: 5), () { Timer(Duration(seconds: 5), () {
if (_state != RoomState.Disconnected) {
return;
}
_state = RoomState.Disconnected; _state = RoomState.Disconnected;
_connectCompleter?.completeError(ConnectError()); _connectCompleter?.completeError(ConnectError());
_connectCompleter = null; _connectCompleter = null;
@@ -173,7 +184,8 @@ class Room extends ChangeNotifier with ParticipantDelegate {
} }
for (var p in _participants.values) { for (var p in _participants.values) {
for (var pub in p.tracks.values) { var tracks = List<TrackPublication>.from(p.tracks.values);
for (var pub in tracks) {
p.unpublishTrack(pub.sid); p.unpublishTrack(pub.sid);
} }
} }
+87 -11
View File
@@ -3,6 +3,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'errors.dart'; import 'errors.dart';
import 'extensions.dart'; import 'extensions.dart';
import 'logger.dart';
import 'proto/livekit_rtc.pb.dart'; import 'proto/livekit_rtc.pb.dart';
import 'proto/livekit_models.pb.dart'; import 'proto/livekit_models.pb.dart';
import 'signal_client.dart'; import 'signal_client.dart';
@@ -12,6 +13,8 @@ import 'transport.dart';
const lossyDataChannel = '_lossy'; const lossyDataChannel = '_lossy';
const reliableDataChannel = '_reliable'; const reliableDataChannel = '_reliable';
final connectionTimeout = new Duration(seconds: 5); final connectionTimeout = new Duration(seconds: 5);
final maxReconnectAttempts = 5;
final iceRestartTimeout = new Duration(seconds: 10);
typedef GenericCallback = void Function(); typedef GenericCallback = void Function();
typedef TrackCallback = void Function( typedef TrackCallback = void Function(
@@ -33,8 +36,10 @@ class RTCEngine with SignalClientDelegate {
RTCDataChannel? reliableDC; RTCDataChannel? reliableDC;
RTCDataChannel? lossyDC; RTCDataChannel? lossyDC;
bool iceConnected = false; bool iceConnected = false;
bool isReconnecting = false;
bool isClosed = true; bool isClosed = true;
Map<String, Completer<TrackInfo>> pendingTrackResolvers = {}; Map<String, Completer<TrackInfo>> pendingTrackResolvers = {};
int reconnectAttempts = 0;
// to complete join request // to complete join request
Completer<JoinResponse>? joinCompleter; Completer<JoinResponse>? joinCompleter;
// remember url and token for reconnect // remember url and token for reconnect
@@ -47,6 +52,8 @@ class RTCEngine with SignalClientDelegate {
ParticipantUpdateCallback? onParticipantUpdateCallback; ParticipantUpdateCallback? onParticipantUpdateCallback;
ActiveSpeakerChangedCallback? onActiveSpeakerchangedCallback; ActiveSpeakerChangedCallback? onActiveSpeakerchangedCallback;
DataPacketCallback? onDataMessageCallback; DataPacketCallback? onDataMessageCallback;
GenericCallback? onReconnecting;
GenericCallback? onReconnected;
GenericCallback? onDisconnected; GenericCallback? onDisconnected;
RTCEngine(this.client, RTCConfiguration? rtcConfig) { RTCEngine(this.client, RTCConfiguration? rtcConfig) {
@@ -112,17 +119,13 @@ class RTCEngine with SignalClientDelegate {
return completer.future; return completer.future;
} }
negotiate([Map<String, dynamic>? constraints]) async { negotiate({bool? iceRestart}) async {
var pub = this.publisher; var pub = this.publisher;
if (pub == null) { if (pub == null) {
return; return;
} }
RTCSessionDescription? remoteDesc; var remoteDesc = await pub.getRemoteDescription();
if (pub.pc.iceConnectionState != null) {
// when not initially connected, this crashes on iOS
remoteDesc = await pub.pc.getRemoteDescription();
}
// handle cases that we couldn't create a new offer due to a pending answer // handle cases that we couldn't create a new offer due to a pending answer
// that's lost in transit // that's lost in transit
@@ -132,14 +135,64 @@ class RTCEngine with SignalClientDelegate {
await pub.pc.setRemoteDescription(remoteDesc); await pub.pc.setRemoteDescription(remoteDesc);
} }
if (constraints == null) { var constraints = <String, dynamic>{};
constraints = {}; if (iceRestart != null && iceRestart) {
constraints['mandatory'] = {
'IceRestart': true,
};
} }
var offer = await pub.pc.createOffer(constraints); var offer = await pub.pc.createOffer(constraints);
await pub.pc.setLocalDescription(offer); await pub.pc.setLocalDescription(offer);
client.sendOffer(offer); client.sendOffer(offer);
} }
Future<void> reconnect() async {
if (isClosed) {
return;
}
var url = this.url;
var token = this.token;
if (url == null || token == null) {
throw ConnectError("could not reconnect without url and token");
}
if (reconnectAttempts == 0) {
onReconnecting?.call();
}
reconnectAttempts++;
try {
isReconnecting = true;
await client.reconnect(url, token);
var pub = this.publisher;
var sub = this.subscriber;
if (pub == null || sub == null) {
throw UnexpectedConnectionState('publisher or subscribers is null');
}
pub.restartingIce = true;
sub.restartingIce = true;
await negotiate(iceRestart: true);
} catch (e) {
isReconnecting = false;
return Future.error(e);
}
// wait for connectivity to change
var startTime = DateTime.now();
while (DateTime.now().difference(startTime) < iceRestartTimeout) {
if (iceConnected) {
isReconnecting = false;
return;
}
await Future.delayed(Duration(milliseconds: 100));
}
isReconnecting = false;
return Future.error(ConnectError('could not reconnect ICE'));
}
_configurePeerConnections() async { _configurePeerConnections() async {
if (publisher != null) { if (publisher != null) {
return; return;
@@ -174,11 +227,16 @@ class RTCEngine with SignalClientDelegate {
case RTCIceConnectionState.RTCIceConnectionStateConnected: case RTCIceConnectionState.RTCIceConnectionStateConnected:
if (!iceConnected) { if (!iceConnected) {
iceConnected = true; iceConnected = true;
onICEConnected?.call(); if (isReconnecting) {
onReconnected?.call();
} else {
onICEConnected?.call();
}
} }
break; break;
case RTCIceConnectionState.RTCIceConnectionStateFailed: case RTCIceConnectionState.RTCIceConnectionStateFailed:
iceConnected = false;
// trigger reconnect sequence // trigger reconnect sequence
_handleDisconnect('peerconnection'); _handleDisconnect('peerconnection');
break; break;
@@ -230,7 +288,25 @@ class RTCEngine with SignalClientDelegate {
} }
_handleDisconnect(String reason) { _handleDisconnect(String reason) {
// TODO: implement method if (isClosed) {
return;
}
logger.fine('disconnected $reason');
if (this.reconnectAttempts >= maxReconnectAttempts) {
logger.info('could not connect after $reconnectAttempts, giving up');
this.close();
onDisconnected?.call();
return;
}
var delay = (reconnectAttempts * reconnectAttempts) * 300;
Future.delayed(Duration(milliseconds: delay), () {
reconnect().then((_) {
reconnectAttempts = 0;
}).catchError((e) {
_handleDisconnect(reason);
});
});
} }
//------------------ SignalClient Delegate methods -------------------------// //------------------ SignalClient Delegate methods -------------------------//
@@ -263,7 +339,7 @@ class RTCEngine with SignalClientDelegate {
} }
void onClose([String? reason]) { void onClose([String? reason]) {
// TODO: handle reconnect when signal interrupted _handleDisconnect("signal");
} }
void onOffer(RTCSessionDescription sd) async { void onOffer(RTCSessionDescription sd) async {
+23 -8
View File
@@ -7,6 +7,9 @@ import './track/track.dart';
import './version.dart'; import './version.dart';
import './proto/livekit_models.pb.dart'; import './proto/livekit_models.pb.dart';
import './proto/livekit_rtc.pb.dart'; import './proto/livekit_rtc.pb.dart';
import '_websocket_api.dart'
if (dart.library.io) '_websocket_io.dart'
if (dart.library.html) '_websocket_html.dart' as platform;
class JoinOptions { class JoinOptions {
final bool? autoSubscribe; final bool? autoSubscribe;
@@ -53,18 +56,30 @@ class SignalClient {
} }
var uri = Uri.parse(url + params); var uri = Uri.parse(url + params);
try { platform.connectToWebSocket(uri).then((ws) {
var ws = WebSocketChannel.connect(uri);
ws.stream ws.stream
.listen(_handleMessage, onError: _handleError, onDone: _handleDone); .listen(_handleMessage, onError: _handleError, onDone: _handleDone);
_ws = ws; _ws = ws;
} catch (e) { }).catchError((error) {
// failed before error handler is installed, fail immediately // TODO: ping api endpoint
_handleError(e); _handleError(error);
} });
} }
Future<void> reconnect(String url, String token) async {} Future<void> reconnect(String url, String token) async {
_connected = false;
_ws?.sink.close();
_ws = null;
url += '/rtc';
var params = _paramsForToken(token);
params += '&reconnect=1';
var uri = Uri.parse(url + params);
var ws = await platform.connectToWebSocket(uri);
_ws = ws;
_connected = true;
}
close() { close() {
this._connected = false; this._connected = false;
@@ -147,7 +162,7 @@ class SignalClient {
_sendRequest(SignalRequest req) { _sendRequest(SignalRequest req) {
if (this._ws == null) { if (this._ws == null) {
log('could not send message, not connected: ' + jsonEncode(req)); log('could not send message, not connected');
return; return;
} }
+11 -3
View File
@@ -6,12 +6,13 @@ import 'options.dart';
import 'track.dart'; import 'track.dart';
class LocalAudioTrack extends Track { class LocalAudioTrack extends Track {
MediaStream mediaStream; MediaStream? mediaStream;
LocalAudioTrack(String name, MediaStreamTrack track, this.mediaStream) LocalAudioTrack(String name, MediaStreamTrack track, this.mediaStream)
: super(TrackType.AUDIO, name, track); : super(TrackType.AUDIO, name, track);
Future<LocalAudioTrack> createTrack(LocalAudioTrackOptions? options) async { static Future<LocalAudioTrack> createTrack(
[LocalAudioTrackOptions? options]) async {
try { try {
var stream = await navigator.mediaDevices.getUserMedia({ var stream = await navigator.mediaDevices.getUserMedia({
"audio": true, "audio": true,
@@ -22,9 +23,16 @@ class LocalAudioTrack extends Track {
return Future.error(TrackCreateError()); return Future.error(TrackCreateError());
} }
return LocalAudioTrack("", stream.getVideoTracks().first, stream); return LocalAudioTrack("", stream.getAudioTracks().first, stream);
} catch (e) { } catch (e) {
return Future.error(e); return Future.error(e);
} }
} }
@override
stop() {
super.stop();
mediaStream?.dispose();
mediaStream = null;
}
} }
+3 -3
View File
@@ -8,8 +8,8 @@ class LocalVideoTrack extends VideoTrack {
LocalVideoTrack(String name, MediaStreamTrack mediaTrack, MediaStream stream) LocalVideoTrack(String name, MediaStreamTrack mediaTrack, MediaStream stream)
: super(name, mediaTrack, stream); : super(name, mediaTrack, stream);
Future<LocalVideoTrack> createCameraTrack( static Future<LocalVideoTrack> createCameraTrack(
LocalVideoTrackOptions? options) async { [LocalVideoTrackOptions? options]) async {
if (options == null) { if (options == null) {
options = LocalVideoTrackOptions(params: VideoPresets.qhd); options = LocalVideoTrackOptions(params: VideoPresets.qhd);
} }
@@ -24,7 +24,7 @@ class LocalVideoTrack extends VideoTrack {
return Future.error(TrackCreateError()); return Future.error(TrackCreateError());
} }
return LocalVideoTrack("", stream.getVideoTracks().first, stream); return LocalVideoTrack("camera", stream.getVideoTracks().first, stream);
} catch (e) { } catch (e) {
return Future.error(e); return Future.error(e);
} }
+8 -1
View File
@@ -4,8 +4,15 @@ import '../proto/livekit_models.pb.dart';
import 'track.dart'; import 'track.dart';
class VideoTrack extends Track { class VideoTrack extends Track {
MediaStream mediaStream; MediaStream? mediaStream;
VideoTrack(String name, MediaStreamTrack mediaTrack, this.mediaStream) VideoTrack(String name, MediaStreamTrack mediaTrack, this.mediaStream)
: super(TrackType.VIDEO, name, mediaTrack); : super(TrackType.VIDEO, name, mediaTrack);
@override
stop() {
super.stop();
mediaStream?.dispose();
mediaStream = null;
}
} }
+12 -2
View File
@@ -11,16 +11,26 @@ class PCTransport {
Future<void> setRemoteDescription(RTCSessionDescription sd) async { Future<void> setRemoteDescription(RTCSessionDescription sd) async {
await pc.setRemoteDescription(sd); await pc.setRemoteDescription(sd);
Future.forEach<RTCIceCandidate>(pendingCandidates, (candidate) async { await Future.forEach<RTCIceCandidate>(pendingCandidates, (candidate) async {
await pc.addCandidate(candidate); await pc.addCandidate(candidate);
}); });
pendingCandidates.clear();
restartingIce = false;
} }
Future<void> addIceCandidate(RTCIceCandidate candidate) async { Future<void> addIceCandidate(RTCIceCandidate candidate) async {
var desc = await pc.getRemoteDescription(); var desc = await getRemoteDescription();
if (desc != null && !restartingIce) { if (desc != null && !restartingIce) {
return pc.addCandidate(candidate); return pc.addCandidate(candidate);
} }
pendingCandidates.add(candidate); pendingCandidates.add(candidate);
} }
Future<RTCSessionDescription?> getRemoteDescription() async {
if (pc.iceConnectionState == null) {
return null;
}
return pc.getRemoteDescription();
}
} }
+3 -1
View File
@@ -34,7 +34,9 @@ class _VideoTrackRendererState extends State<VideoTrackRenderer> {
_initRenderer() async { _initRenderer() async {
await _renderer.initialize(); await _renderer.initialize();
_renderer.srcObject = widget.track.mediaStream; setState(() {
_renderer.srcObject = widget.track.mediaStream;
});
} }
@override @override
+1 -1
View File
@@ -1,6 +1,6 @@
name: livekit_client name: livekit_client
description: Flutter client for LiveKit description: Flutter client for LiveKit
version: 0.0.1 version: 0.1.0
homepage: https://livekit.io homepage: https://livekit.io
environment: environment: