Subscriber as primary (#8)

* Update protos

* Signal client

* Improve transport

* First implementation

* Fix publish

* Update `PCTransport` for debounce negotiation

* debounce func

* reconnect & events

* Update debounce func

* engine events

* make sure events don't emit after dispose

* prefix flutter_webrtc

* Cleaner `iceServers` update

* don't mutate user provided params

* fix tests

* event manager

* Fix: `Room.onDisconnected` gets fired multiple times

* data publish in example

* cleaner logic

* safer events

* un-prefix with LK

* organize imports

* Clean up

* remove `Tuple`

* `EventsEmitter` can now be directly listened to

`EventsEmitter` extends `EventsListenable`
This commit is contained in:
Hiroshi Horie
2021-09-18 00:10:16 +09:00
committed by GitHub
parent 50f56c5e1e
commit f4bd82a919
36 changed files with 1334 additions and 588 deletions
+68
View File
@@ -15,4 +15,72 @@ extension LKExampleExt on BuildContext {
], ],
), ),
); );
Future<bool?> showDisconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure to disconnect?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Disconnect'),
),
],
),
);
Future<bool?> showReconnectDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Reconnect'),
content: const Text('This will force a reconnection'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Reconnect'),
),
],
),
);
Future<bool?> showSendDataDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Send data'),
content: const Text('This will send a sample data to all participants in the room'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Send'),
),
],
),
);
Future<bool?> showDataReceivedDialog(String data) => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Received data'),
content: Text('"${data}"'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('OK'),
),
],
),
);
} }
+8 -7
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -6,6 +7,7 @@ import 'package:provider/provider.dart';
import '../widgets/controls.dart'; import '../widgets/controls.dart';
import '../widgets/participant.dart'; import '../widgets/participant.dart';
import '../exts.dart';
class RoomPage extends StatefulWidget { class RoomPage extends StatefulWidget {
// //
@@ -17,13 +19,10 @@ class RoomPage extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
@override @override
State<StatefulWidget> createState() { State<StatefulWidget> createState() => _RoomPageState();
return _RoomPageState();
}
} }
class _RoomPageState extends State<RoomPage> with RoomDelegate { class _RoomPageState extends State<RoomPage> with RoomDelegate {
// BuildContext? _lastContext;
// //
List<Participant> participants = []; List<Participant> participants = [];
@@ -107,13 +106,15 @@ class _RoomPageState extends State<RoomPage> with RoomDelegate {
}); });
} }
@override
void onDataReceived(RemoteParticipant participant, List<int> data) async {
await context.showDataReceivedDialog(utf8.decode(data));
}
@override @override
void onDisconnected() { void onDisconnected() {
// final context = _lastContext;
print('disconnected: $context'); print('disconnected: $context');
// if (context != null) {
Navigator.pop(context); Navigator.pop(context);
// }
} }
@override @override
+30 -4
View File
@@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:eva_icons_flutter/eva_icons_flutter.dart'; import 'package:eva_icons_flutter/eva_icons_flutter.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import '../exts.dart';
class ControlsWidget extends StatefulWidget { class ControlsWidget extends StatefulWidget {
// //
@@ -113,8 +116,23 @@ class _ControlsWidgetState extends State<ControlsWidget> {
} }
} }
void _exit() { void _onTapDisconnect() async {
widget.room.disconnect(); final result = await context.showDisconnectDialog();
if (result == true) await widget.room.disconnect();
}
void _onTapReconnect() async {
final result = await context.showReconnectDialog();
if (result == true) await widget.room.reconnect();
}
void _onTapSendData() async {
final result = await context.showSendDataDialog();
if (result == true) {
await widget.room.localParticipant.publishData(
utf8.encode('This is a sample data message'),
);
}
} }
@override @override
@@ -157,9 +175,17 @@ class _ControlsWidgetState extends State<ControlsWidget> {
onPressed: () => _shareScreen(), onPressed: () => _shareScreen(),
), ),
IconButton( IconButton(
onPressed: _exit, onPressed: _onTapDisconnect,
icon: const Icon(EvaIcons.closeCircle), icon: const Icon(EvaIcons.closeCircle),
) ),
IconButton(
onPressed: _onTapSendData,
icon: const Icon(EvaIcons.paperPlane),
),
IconButton(
onPressed: _onTapReconnect,
icon: const Icon(EvaIcons.refresh),
),
], ],
); );
} }
+1 -15
View File
@@ -162,7 +162,7 @@ packages:
name: logging name: logging
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -268,13 +268,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
quiver:
dependency: transitive
description:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -371,13 +364,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.2" version: "0.4.2"
tuple:
dependency: transitive
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -19,4 +19,5 @@ export 'src/track/remote_track_publication.dart';
export 'src/track/track.dart'; export 'src/track/track.dart';
export 'src/track/track_publication.dart'; export 'src/track/track_publication.dart';
export 'src/track/video_track.dart'; export 'src/track/video_track.dart';
export 'src/types.dart' show RTCConfiguration, RTCIceServer, RTCIceTransportPolicy, Reliability;
export 'src/widget/video_track_renderer.dart'; export 'src/widget/video_track_renderer.dart';
+16 -14
View File
@@ -1,32 +1,34 @@
// //
// `Exception` implies runtime errors while, an `Error` object //
// represents a program failure that the programmer
// should have avoided.
// //
class LiveKitException implements Exception { class LiveKitException implements Exception {
final String message; final String message;
const LiveKitException._(this.message); const LiveKitException._(this.message);
@override @override
String toString() => 'LiveKitException $runtimeType $message'; String toString() => 'LiveKit Exception $runtimeType $message';
} }
class ConnectError extends LiveKitException { class ConnectException extends LiveKitException {
ConnectError([String msg = 'Failed to connect to server']) : super._(msg); ConnectException([String msg = 'Failed to connect to server']) : super._(msg);
} }
class UnexpectedConnectionState extends LiveKitException { class UnexpectedStateException extends LiveKitException {
UnexpectedConnectionState([String msg = 'Unexpected connection state']) : super._(msg); UnexpectedStateException([String msg = 'Unexpected connection state']) : super._(msg);
} }
class TrackCreateError extends LiveKitException { class TrackCreateException extends LiveKitException {
TrackCreateError([String msg = 'Failed to create track']) : super._(msg); TrackCreateException([String msg = 'Failed to create track']) : super._(msg);
} }
class TrackPublishError extends LiveKitException { class TrackPublishException extends LiveKitException {
TrackPublishError([String msg = 'Failed to publish track']) : super._(msg); TrackPublishException([String msg = 'Failed to publish track']) : super._(msg);
} }
class DataPublishError extends LiveKitException { class DataPublishException extends LiveKitException {
DataPublishError([String msg = 'Failed to publish data']) : super._(msg); DataPublishException([String msg = 'Failed to publish data']) : super._(msg);
}
class TimeoutException extends LiveKitException {
TimeoutException([String msg = 'Timeout']) : super._(msg);
} }
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'proto/livekit_models.pb.dart' as lk_models;
abstract class LiveKitEvent {}
abstract class RoomEvent implements LiveKitEvent {
const RoomEvent();
}
abstract class ParticipantEvent implements LiveKitEvent {
const ParticipantEvent();
}
abstract class EngineEvent implements LiveKitEvent {
const EngineEvent();
}
abstract class TrackEvent implements LiveKitEvent {
const TrackEvent();
}
//
// Room events
//
class RoomReconnectingEvent extends RoomEvent {}
class RoomReconnectedEvent extends RoomEvent {}
class RoomDisconnectedEvent extends RoomEvent {}
class RoomParticipantConnectedEvent extends RoomEvent {}
class RoomParticipantDisconnectedEvent extends RoomEvent {}
class RoomTrackPublishedEvent extends RoomEvent {}
class RoomTrackSubscribedEvent extends RoomEvent {}
class RoomTrackSubscriptionFailedEvent extends RoomEvent {}
class RoomTrackUnpublishedEvent extends RoomEvent {}
class RoomTrackUnsubscribedEvent extends RoomEvent {}
class RoomTrackMutedEvent extends RoomEvent {}
class RoomTrackUnmutedEvent extends RoomEvent {}
class RoomActiveSpeakerChangedEvent extends RoomEvent {}
class RoomMetadataChangedEvent extends RoomEvent {}
class RoomDataReceivedEvent extends RoomEvent {}
class RoomAudioPlaybackChangedEvent extends RoomEvent {}
//
// Participant events
//
class ParticipantTrackPublishedEvent extends ParticipantEvent {}
class ParticipantTrackSubscribedEvent extends ParticipantEvent {}
class ParticipantTrackSubscriptionFailedEvent extends ParticipantEvent {}
class ParticipantTrackUnpublishedEvent extends ParticipantEvent {}
class ParticipantTrackUnsubscribedEvent extends ParticipantEvent {}
class ParticipantTrackMutedEvent extends ParticipantEvent {}
class ParticipantTrackUnmutedEvent extends ParticipantEvent {}
class ParticipantMetadataChangedEvent extends ParticipantEvent {}
class ParticipantDataReceivedEvent extends ParticipantEvent {}
class ParticipantSpeakingChangedEvent extends ParticipantEvent {}
//
// Engine events
//
class EngineConnectedEvent extends EngineEvent {}
class EngineDisconnectedEvent extends EngineEvent {}
class EngineReconnectingEvent extends EngineEvent {}
class EngineReconnectedEvent extends EngineEvent {}
class EngineParticipantUpdateEvent extends EngineEvent {
final List<lk_models.ParticipantInfo> participants;
const EngineParticipantUpdateEvent({
required this.participants,
});
}
class EngineMediaTrackAddedEvent extends EngineEvent {
final rtc.MediaStreamTrack track;
final rtc.MediaStream? stream;
final rtc.RTCRtpReceiver? receiver;
const EngineMediaTrackAddedEvent({
required this.track,
required this.stream,
required this.receiver,
});
}
class EngineSpeakersUpdateEvent extends EngineEvent {
final List<lk_models.SpeakerInfo> speakers;
const EngineSpeakersUpdateEvent({
required this.speakers,
});
}
class EngineDataPacketReceivedEvent extends EngineEvent {
final lk_models.UserPacket packet;
final lk_models.DataPacket_Kind kind;
const EngineDataPacketReceivedEvent({
required this.packet,
required this.kind,
});
}
class EngineRemoteMuteChangedEvent extends EngineEvent {
final String sid;
final bool muted;
const EngineRemoteMuteChangedEvent({
required this.sid,
required this.muted,
});
}
// added
abstract class EngineIceStateUpdatedEvent implements EngineEvent {
final rtc.RTCIceConnectionState iceState;
final bool isPrimary;
const EngineIceStateUpdatedEvent({
required this.iceState,
required this.isPrimary,
});
}
class EngineSubscriberIceStateUpdatedEvent extends EngineIceStateUpdatedEvent {
const EngineSubscriberIceStateUpdatedEvent({
required rtc.RTCIceConnectionState state,
required bool isPrimary,
}) : super(
iceState: state,
isPrimary: isPrimary,
);
}
class EnginePublisherIceStateUpdatedEvent extends EngineIceStateUpdatedEvent {
const EnginePublisherIceStateUpdatedEvent({
required rtc.RTCIceConnectionState state,
required bool isPrimary,
}) : super(
iceState: state,
isPrimary: isPrimary,
);
}
//
// Track events
//
class TrackMessageEvent extends TrackEvent {}
class TrackMutedEvent extends TrackEvent {}
class TrackUnmutedEvent extends TrackEvent {}
class TrackUpdateSettingsEvent extends TrackEvent {}
class TrackUpdateSubscriptionEvent extends TrackEvent {}
class TrackAudioPlaybackStartedEvent extends TrackEvent {}
class TrackAudioPlaybackFailedEvent extends TrackEvent {}
+64 -35
View File
@@ -1,6 +1,25 @@
enum RTCIceTransportPolicy { import 'dart:convert';
all,
relay, import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'proto/livekit_rtc.pb.dart' as lk_rtc;
import 'proto/livekit_models.pb.dart' as lk_models;
import 'types.dart';
extension IterableExt<E> on Iterable<E> {
E? elementAtOrNull(int index) => (index >= 0 && index < length) ? elementAt(index) : null;
}
extension RTCIceConnectionStateExt on rtc.RTCIceConnectionState {
bool isConnected() => [
rtc.RTCIceConnectionState.RTCIceConnectionStateConnected,
rtc.RTCIceConnectionState.RTCIceConnectionStateCompleted,
].contains(this);
}
extension ObjectExt on Object {
String get objectId => '${runtimeType}#${hashCode}';
} }
extension RTCIceTransportPolicyExt on RTCIceTransportPolicy { extension RTCIceTransportPolicyExt on RTCIceTransportPolicy {
@@ -10,41 +29,51 @@ extension RTCIceTransportPolicyExt on RTCIceTransportPolicy {
}[this]!; }[this]!;
} }
class RTCConfiguration { extension SessionDescriptionExt on lk_rtc.SessionDescription {
int? iceCandidatePoolSize; rtc.RTCSessionDescription toSDKType() {
List<RTCIceServer>? iceServers; return rtc.RTCSessionDescription(sdp, type);
RTCIceTransportPolicy? iceTransportPolicy;
Map<String, dynamic> toMap() {
final iceServersMap = <Map<String, dynamic>>[
if (iceServers != null)
for (final element in iceServers!) element.toMap()
];
return <String, dynamic>{
// only supports unified plan
'sdpSemantics': 'unified-plan',
if (iceServersMap.isNotEmpty) 'iceServers': iceServersMap,
if (iceCandidatePoolSize != null) 'iceCandidatePoolSize': iceCandidatePoolSize,
if (iceTransportPolicy != null) 'iceTransportPolicy': iceTransportPolicy!.toStringValue(),
};
} }
} }
class RTCIceServer { extension RTCSessionDescriptionExt on rtc.RTCSessionDescription {
List<String> urls; lk_rtc.SessionDescription toSDKType() {
String? username; return lk_rtc.SessionDescription(type: type, sdp: sdp);
String? credential; }
}
RTCIceServer({ extension RTCIceCandidateExt on rtc.RTCIceCandidate {
required this.urls, static rtc.RTCIceCandidate fromJson(String jsonString) {
this.username, final map = json.decode(jsonString) as Map<String, dynamic>;
this.credential, return rtc.RTCIceCandidate(
}); map['candidate'] as String?,
map['sdpMid'] as String?,
map['sdpMLineIndex'] as int?,
);
}
Map<String, dynamic> toMap() => <String, dynamic>{ String toJson() => json.encode(toMap());
'urls': urls, }
if (username != null) 'username': username,
if (credential != null) 'credential': credential, extension ICEServerExt on lk_rtc.ICEServer {
}; RTCIceServer toSDKType() => RTCIceServer(
urls: urls,
username: username.isNotEmpty ? username : null,
credential: credential.isNotEmpty ? username : null,
);
}
// not so neat to directly expose protobuf types so we
// define our own types (and convert methods)
extension DataPacketKindExt on lk_models.DataPacket_Kind {
Reliability toSDKType() => {
lk_models.DataPacket_Kind.RELIABLE: Reliability.reliable,
lk_models.DataPacket_Kind.LOSSY: Reliability.lossy,
}[this]!;
}
extension ReliabilityExt on Reliability {
lk_models.DataPacket_Kind toPBType() => {
Reliability.reliable: lk_models.DataPacket_Kind.RELIABLE,
Reliability.lossy: lk_models.DataPacket_Kind.LOSSY,
}[this]!;
} }
+34
View File
@@ -0,0 +1,34 @@
//
//
//
import 'package:async/async.dart';
class CancelableDelayManager {
//
final _delays = <CancelableOperation<void>>[];
// delay but cancelable
Future<void> waitFor(
Duration wait, {
Function? ifNotCancelled,
}) async {
final op = CancelableOperation<void>.fromFuture(
Future<void>.delayed(wait),
);
_delays.add(op);
await op.valueOrCancellation();
_delays.remove(op);
// if it was cancelled we probably don't want to execute it
if (!op.isCanceled) ifNotCancelled?.call();
}
Future<void> dispose() async {
// cancel all delays
if (_delays.isEmpty) return;
// make a copy so we don't mutate while iterating
final snapshot = List<CancelableOperation<void>>.from(_delays);
for (final op in snapshot) {
await op.cancel();
}
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../errors.dart';
import '../events.dart';
import '../extensions.dart';
import '../logger.dart';
import '../types.dart';
// Type-safe, multi-listenable, dispose safe event handling
class EventsEmitter<T extends LiveKitEvent> extends EventsListenable<T> {
// suppport for multiple event listeners
final streamCtrl = StreamController<T>.broadcast(sync: false);
@override
EventsEmitter<T> get emitter => this;
void emit(T event) {
// do nothing if already closed
if (streamCtrl.isClosed) return;
// emit the event
streamCtrl.add(event);
}
@override
Future<void> dispose() async {
await streamCtrl.close();
await super.dispose();
}
}
// for listening only
class EventsListener<T extends LiveKitEvent> extends EventsListenable<T> {
@override
final EventsEmitter<T> emitter;
EventsListener({
required this.emitter,
});
}
// ensures all listeners will close on dispose
abstract class EventsListenable<T extends LiveKitEvent> {
// the emitter to listen to
EventsEmitter<T> get emitter;
// keep track of listeners to cancel later
final _listeners = <StreamSubscription<T>>[];
@mustCallSuper
Future<void> dispose() async {
// Stop listening to all events
logger.fine('${objectId} dispose() cancelling ${_listeners.length} event(s)');
for (final listener in _listeners) {
await listener.cancel();
}
}
// listens to all events, guaranteed to be cancelled on dispose
CancelListenFunc listen(Function(T) onEvent) {
final listener = emitter.streamCtrl.stream.listen(onEvent);
_listeners.add(listener);
// make a cancel func to cancel listening and remove from list in 1 call
_cancelFunc() async {
await listener.cancel();
_listeners.remove(listener);
logger.fine('${objectId} event was cancelled by func');
}
return _cancelFunc;
}
// convenience method to listen & filter a specific event type
CancelListenFunc on<E>(
Function(E) then, {
bool Function(E)? filter,
}) =>
listen((event) {
// event must be E
if (event is! E) return;
// filter must be true (if filter is used)
if (filter != null && !filter(event as E)) return;
// cast to E
then(event as E);
});
// waits for a specific event type
Future<void> waitFor<E>({
required Duration duration,
bool Function(E)? filter,
FutureOr<void> Function()? onTimeout,
}) async {
final completer = Completer<void>();
final _cancelFunc = on<E>(
(event) => completer.complete(),
filter: filter,
);
try {
// wait to complete with timeout
await completer.future.timeout(
duration,
onTimeout: onTimeout ?? () => throw TimeoutException(),
);
// do not catch exceptions and pass it up
} finally {
// always clean-up listener
await _cancelFunc.call();
}
}
}
+23 -34
View File
@@ -1,7 +1,8 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../errors.dart'; import '../errors.dart';
import '../extensions.dart';
import '../logger.dart'; import '../logger.dart';
import '../options.dart'; import '../options.dart';
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
@@ -11,6 +12,7 @@ import '../track/local_track_publication.dart';
import '../track/local_video_track.dart'; import '../track/local_video_track.dart';
import '../track/track.dart'; import '../track/track.dart';
import '../track/track_publication.dart'; import '../track/track_publication.dart';
import '../types.dart';
import '../utils.dart'; import '../utils.dart';
import 'participant.dart'; import 'participant.dart';
@@ -35,7 +37,7 @@ class LocalParticipant extends Participant {
/// publish an audio track to the room /// publish an audio track to the room
Future<TrackPublication> publishAudioTrack(LocalAudioTrack track) async { Future<TrackPublication> publishAudioTrack(LocalAudioTrack track) async {
if (audioTracks.any((e) => e.track?.mediaStreamTrack.id == track.mediaStreamTrack.id)) { if (audioTracks.any((e) => e.track?.mediaStreamTrack.id == track.mediaStreamTrack.id)) {
throw TrackPublishError('track already exists'); throw TrackPublishException('track already exists');
} }
// try { // try {
@@ -45,14 +47,16 @@ class LocalParticipant extends Participant {
kind: track.kind, kind: track.kind,
); );
final transceiverInit = RTCRtpTransceiverInit( final transceiverInit = rtc.RTCRtpTransceiverInit(
direction: TransceiverDirection.SendOnly, direction: rtc.TransceiverDirection.SendOnly,
); );
// addTransceiver cannot pass in a kind parameter due to a bug in flutter-webrtc (web) // addTransceiver cannot pass in a kind parameter due to a bug in flutter-webrtc (web)
track.transceiver = await _engine.publisher?.pc.addTransceiver( track.transceiver = await _engine.publisher?.pc.addTransceiver(
track: track.mediaStreamTrack, track: track.mediaStreamTrack,
kind: rtc.RTCRtpMediaType.RTCRtpMediaTypeAudio,
init: transceiverInit, init: transceiverInit,
); );
await _engine.negotiate();
final pub = LocalTrackPublication(trackInfo, track, this); final pub = LocalTrackPublication(trackInfo, track, this);
addTrackPublication(pub); addTrackPublication(pub);
@@ -67,7 +71,7 @@ class LocalParticipant extends Participant {
TrackPublishOptions? options, TrackPublishOptions? options,
}) async { }) async {
if (videoTracks.any((e) => e.track?.mediaStreamTrack.id == track.mediaStreamTrack.id)) { if (videoTracks.any((e) => e.track?.mediaStreamTrack.id == track.mediaStreamTrack.id)) {
throw TrackPublishError('track already exists'); throw TrackPublishException('track already exists');
} }
// Use default options from `ConnectOptions` if options is null // Use default options from `ConnectOptions` if options is null
@@ -78,10 +82,9 @@ class LocalParticipant extends Participant {
name: track.name, name: track.name,
kind: track.kind, kind: track.kind,
); );
logger.fine('publishVideoTrack addTrack response: ${trackInfo}');
//
// Video encodings and simulcasts // Video encodings and simulcasts
//
// use constraints passed to getUserMedia by default // use constraints passed to getUserMedia by default
int? width = track.currentOptions.params.width; int? width = track.currentOptions.params.width;
@@ -94,8 +97,6 @@ class LocalParticipant extends Participant {
final settings = track.mediaStreamTrack.getSettings(); final settings = track.mediaStreamTrack.getSettings();
width = settings['width'] as int?; width = settings['width'] as int?;
height = settings['height'] as int?; height = settings['height'] as int?;
// TODO: Get actual video dimensions to compute more accurately
// mediaTrack.getConsstraints() is not implemented for mobile
} catch (_) { } catch (_) {
logger.warning('Failed to call `mediaStreamTrack.getSettings()`'); logger.warning('Failed to call `mediaStreamTrack.getSettings()`');
} }
@@ -111,19 +112,20 @@ class LocalParticipant extends Participant {
logger.fine('Using encodings: ${encodings?.map((e) => e.toMap())}'); logger.fine('Using encodings: ${encodings?.map((e) => e.toMap())}');
final transceiverInit = RTCRtpTransceiverInit( final transceiverInit = rtc.RTCRtpTransceiverInit(
direction: TransceiverDirection.SendOnly, direction: rtc.TransceiverDirection.SendOnly,
sendEncodings: encodings, sendEncodings: encodings,
streams: [track.mediaStream], streams: [track.mediaStream],
); );
// logger.fine('publishVideoTrack publisher: ${_engine.publisher}');
// addTransceiver cannot pass in a kind parameter due to a bug in flutter-webrtc (web)
//
track.transceiver = await _engine.publisher?.pc.addTransceiver( track.transceiver = await _engine.publisher?.pc.addTransceiver(
track: track.mediaStreamTrack, track: track.mediaStreamTrack,
kind: rtc.RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: transceiverInit, init: transceiverInit,
); );
await _engine.negotiate();
final pub = LocalTrackPublication(trackInfo, track, this); final pub = LocalTrackPublication(trackInfo, track, this);
addTrackPublication(pub); addTrackPublication(pub);
@@ -144,6 +146,7 @@ class LocalParticipant extends Participant {
final sender = track.transceiver?.sender; final sender = track.transceiver?.sender;
if (sender != null) { if (sender != null) {
await engine.publisher?.pc.removeTrack(sender); await engine.publisher?.pc.removeTrack(sender);
await engine.negotiate();
} }
tracks.remove(pub.sid); tracks.remove(pub.sid);
@@ -151,26 +154,13 @@ class LocalParticipant extends Participant {
/// Publish a new data payload to the room. /// Publish a new data payload to the room.
/// @param destinationSids When empty, data will be forwarded to each participant in the room. /// @param destinationSids When empty, data will be forwarded to each participant in the room.
void publishData( Future<void> publishData(
List<int> data, List<int> data, {
lk_models.DataPacket_Kind reliability, { Reliability reliability = Reliability.reliable,
List<String>? destinationSids, List<String>? destinationSids,
}) { }) async {
RTCDataChannel? channel;
switch (reliability) {
case lk_models.DataPacket_Kind.RELIABLE:
channel = engine.reliableDC;
break;
case lk_models.DataPacket_Kind.LOSSY:
channel = engine.lossyDC;
break;
}
if (channel == null) {
return;
}
final packet = lk_models.DataPacket( final packet = lk_models.DataPacket(
kind: reliability, kind: reliability.toPBType(),
user: lk_models.UserPacket( user: lk_models.UserPacket(
payload: data, payload: data,
participantSid: sid, participantSid: sid,
@@ -178,8 +168,7 @@ class LocalParticipant extends Participant {
), ),
); );
final buffer = packet.writeToBuffer(); await engine.sendDataPacket(packet);
channel.send(RTCDataChannelMessage.fromBinary(buffer));
} }
/// for internal use /// for internal use
+6 -1
View File
@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../events.dart';
import '../managers/event.dart';
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
import '../track/remote_track_publication.dart'; import '../track/remote_track_publication.dart';
import '../track/track.dart'; import '../track/track.dart';
@@ -77,6 +79,9 @@ class Participant extends ChangeNotifier {
lk_models.ParticipantInfo? _participantInfo; lk_models.ParticipantInfo? _participantInfo;
bool _isSpeaking = false; bool _isSpeaking = false;
// suppport for multiple event listeners
final events = EventsEmitter<ParticipantEvent>();
/// when the participant joined the room /// when the participant joined the room
DateTime get joinedAt { DateTime get joinedAt {
final pi = _participantInfo; final pi = _participantInfo;
@@ -159,7 +164,7 @@ class Participant extends ChangeNotifier {
} }
// Convenience extension // Convenience extension
extension LKParticipantExt on Participant { extension ParticipantExt on Participant {
List<TrackPublication> get videoTracks => List<TrackPublication> get videoTracks =>
tracks.values.where((e) => e.kind == lk_models.TrackType.VIDEO).toList(); tracks.values.where((e) => e.kind == lk_models.TrackType.VIDEO).toList();
+6 -2
View File
@@ -1,4 +1,4 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../logger.dart'; import '../logger.dart';
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
@@ -35,7 +35,11 @@ class RemoteParticipant extends Participant {
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
void addSubscribedMediaTrack(MediaStreamTrack mediaTrack, MediaStream stream, String? sid) async { void addSubscribedMediaTrack(
rtc.MediaStreamTrack mediaTrack,
rtc.MediaStream stream,
String? sid,
) async {
if (sid == null) { if (sid == null) {
const msg = 'addSubscribedMediaTrack received null sid'; const msg = 'addSubscribedMediaTrack received null sid';
delegate?.onTrackSubscriptionFailed(this, '', msg); delegate?.onTrackSubscriptionFailed(this, '', msg);
+18
View File
@@ -935,6 +935,8 @@ class JoinResponse extends $pb.GeneratedMessage {
const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'iceServers', const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'iceServers',
$pb.PbFieldType.PM, $pb.PbFieldType.PM,
subBuilder: ICEServer.create) subBuilder: ICEServer.create)
..aOB(
6, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'subscriberPrimary')
..hasRequiredFields = false; ..hasRequiredFields = false;
JoinResponse._() : super(); JoinResponse._() : super();
@@ -944,6 +946,7 @@ class JoinResponse extends $pb.GeneratedMessage {
$core.Iterable<$0.ParticipantInfo>? otherParticipants, $core.Iterable<$0.ParticipantInfo>? otherParticipants,
$core.String? serverVersion, $core.String? serverVersion,
$core.Iterable<ICEServer>? iceServers, $core.Iterable<ICEServer>? iceServers,
$core.bool? subscriberPrimary,
}) { }) {
final _result = create(); final _result = create();
if (room != null) { if (room != null) {
@@ -961,6 +964,9 @@ class JoinResponse extends $pb.GeneratedMessage {
if (iceServers != null) { if (iceServers != null) {
_result.iceServers.addAll(iceServers); _result.iceServers.addAll(iceServers);
} }
if (subscriberPrimary != null) {
_result.subscriberPrimary = subscriberPrimary;
}
return _result; return _result;
} }
factory JoinResponse.fromBuffer($core.List<$core.int> i, factory JoinResponse.fromBuffer($core.List<$core.int> i,
@@ -1034,6 +1040,18 @@ class JoinResponse extends $pb.GeneratedMessage {
@$pb.TagNumber(5) @$pb.TagNumber(5)
$core.List<ICEServer> get iceServers => $_getList(4); $core.List<ICEServer> get iceServers => $_getList(4);
@$pb.TagNumber(6)
$core.bool get subscriberPrimary => $_getBF(5);
@$pb.TagNumber(6)
set subscriberPrimary($core.bool v) {
$_setBool(5, v);
}
@$pb.TagNumber(6)
$core.bool hasSubscriberPrimary() => $_has(5);
@$pb.TagNumber(6)
void clearSubscriberPrimary() => clearField(6);
} }
class TrackPublishedResponse extends $pb.GeneratedMessage { class TrackPublishedResponse extends $pb.GeneratedMessage {
+2 -1
View File
@@ -304,12 +304,13 @@ const JoinResponse$json = const {
'6': '.livekit.ICEServer', '6': '.livekit.ICEServer',
'10': 'iceServers' '10': 'iceServers'
}, },
const {'1': 'subscriber_primary', '3': 6, '4': 1, '5': 8, '10': 'subscriberPrimary'},
], ],
}; };
/// Descriptor for `JoinResponse`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `JoinResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List joinResponseDescriptor = $convert.base64Decode( final $typed_data.Uint8List joinResponseDescriptor = $convert.base64Decode(
'CgxKb2luUmVzcG9uc2USIQoEcm9vbRgBIAEoCzINLmxpdmVraXQuUm9vbVIEcm9vbRI6CgtwYXJ0aWNpcGFudBgCIAEoCzIYLmxpdmVraXQuUGFydGljaXBhbnRJbmZvUgtwYXJ0aWNpcGFudBJHChJvdGhlcl9wYXJ0aWNpcGFudHMYAyADKAsyGC5saXZla2l0LlBhcnRpY2lwYW50SW5mb1IRb3RoZXJQYXJ0aWNpcGFudHMSJQoOc2VydmVyX3ZlcnNpb24YBCABKAlSDXNlcnZlclZlcnNpb24SMwoLaWNlX3NlcnZlcnMYBSADKAsyEi5saXZla2l0LklDRVNlcnZlclIKaWNlU2VydmVycw=='); 'CgxKb2luUmVzcG9uc2USIQoEcm9vbRgBIAEoCzINLmxpdmVraXQuUm9vbVIEcm9vbRI6CgtwYXJ0aWNpcGFudBgCIAEoCzIYLmxpdmVraXQuUGFydGljaXBhbnRJbmZvUgtwYXJ0aWNpcGFudBJHChJvdGhlcl9wYXJ0aWNpcGFudHMYAyADKAsyGC5saXZla2l0LlBhcnRpY2lwYW50SW5mb1IRb3RoZXJQYXJ0aWNpcGFudHMSJQoOc2VydmVyX3ZlcnNpb24YBCABKAlSDXNlcnZlclZlcnNpb24SMwoLaWNlX3NlcnZlcnMYBSADKAsyEi5saXZla2l0LklDRVNlcnZlclIKaWNlU2VydmVycxItChJzdWJzY3JpYmVyX3ByaW1hcnkYBiABKAhSEXN1YnNjcmliZXJQcmltYXJ5');
@$core.Deprecated('Use trackPublishedResponseDescriptor instead') @$core.Deprecated('Use trackPublishedResponseDescriptor instead')
const TrackPublishedResponse$json = const { const TrackPublishedResponse$json = const {
'1': 'TrackPublishedResponse', '1': 'TrackPublishedResponse',
+59 -39
View File
@@ -2,12 +2,13 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:tuple/tuple.dart';
import 'errors.dart'; import 'errors.dart';
import 'events.dart';
import 'extensions.dart'; import 'extensions.dart';
import 'logger.dart'; import 'logger.dart';
import 'managers/event.dart';
import 'options.dart'; import 'options.dart';
import 'participant/local_participant.dart'; import 'participant/local_participant.dart';
import 'participant/participant.dart'; import 'participant/participant.dart';
@@ -18,6 +19,7 @@ import 'signal_client.dart';
import 'track/remote_track_publication.dart'; import 'track/remote_track_publication.dart';
import 'track/track.dart'; import 'track/track.dart';
import 'track/track_publication.dart'; import 'track/track_publication.dart';
import 'types.dart';
enum RoomState { enum RoomState {
disconnected, disconnected,
@@ -100,10 +102,10 @@ mixin RoomDelegate {
/// * active speakers are different /// * active speakers are different
/// {@category Room} /// {@category Room}
class Room extends ChangeNotifier with ParticipantDelegate { class Room extends ChangeNotifier with ParticipantDelegate {
RoomState _state = RoomState.disconnected; RoomState _connectionState = RoomState.disconnected;
/// connection state of the room /// connection state of the room
RoomState get state => _state; RoomState get state => _connectionState;
final Map<String, RemoteParticipant> _participants = {}; final Map<String, RemoteParticipant> _participants = {};
@@ -131,7 +133,9 @@ class Room extends ChangeNotifier with ParticipantDelegate {
final RTCEngine _engine; final RTCEngine _engine;
Completer<Room>? _connectCompleter; // suppport for multiple event listeners
final events = EventsEmitter<RoomEvent>();
late final _engineListener = EventsListener<EngineEvent>(emitter: _engine.events);
/// internal use /// internal use
/// {@nodoc} /// {@nodoc}
@@ -144,25 +148,29 @@ class Room extends ChangeNotifier with ParticipantDelegate {
_engine.onDataMessage = _handleDataPacket; _engine.onDataMessage = _handleDataPacket;
_engine.onRemoteMute = _onRemoteMuteChanged; _engine.onRemoteMute = _onRemoteMuteChanged;
_engine.onReconnected = () { _engine.onReconnected = () {
_state = RoomState.connected; _connectionState = RoomState.connected;
delegate?.onReconnected(); delegate?.onReconnected();
notifyListeners(); notifyListeners();
}; };
_engine.onReconnecting = () { _engine.onReconnecting = () {
_state = RoomState.reconnecting; _connectionState = RoomState.reconnecting;
delegate?.onReconnecting(); delegate?.onReconnecting();
notifyListeners(); notifyListeners();
}; };
} }
@override
Future<void> dispose() async {
await events.dispose();
await _engineListener.dispose();
super.dispose();
}
Future<Room> connect( Future<Room> connect(
String url, String url,
String token, { String token, {
ConnectOptions? options, ConnectOptions? options,
}) async { }) async {
final completer = Completer<Room>();
_connectCompleter = completer;
final joinResponse = await _engine.join( final joinResponse = await _engine.join(
url, url,
token, token,
@@ -185,19 +193,24 @@ class Room extends ChangeNotifier with ParticipantDelegate {
_getOrCreateRemoteParticipant(info.sid, info); _getOrCreateRemoteParticipant(info.sid, info);
} }
// room is not ready until ICE is connected. so we would return a completer for now // room is not ready until ICE is connected.
// if it times out, we'll fail the completer try {
Timer(const Duration(seconds: 5), () { await _engineListener.waitFor<EngineIceStateUpdatedEvent>(
if (_state != RoomState.disconnected) { filter: (event) => event.iceState.isConnected(),
return; duration: const Duration(seconds: 5),
} onTimeout: () => throw ConnectException(),
_state = RoomState.disconnected; );
_connectCompleter?.completeError(ConnectError());
_connectCompleter = null;
notifyListeners();
});
return completer.future; // catch any exception
} catch (_) {
_connectionState = RoomState.disconnected;
notifyListeners();
// pass on the exception
rethrow;
}
return this;
} }
/// Disconnects from the room, notifying server of disconnection. /// Disconnects from the room, notifying server of disconnection.
@@ -206,6 +219,10 @@ class Room extends ChangeNotifier with ParticipantDelegate {
await _handleDisconnect(); await _handleDisconnect();
} }
Future<void> reconnect() async {
await _engine.reconnect();
}
RemoteParticipant _getOrCreateRemoteParticipant(String sid, lk_models.ParticipantInfo? info) { RemoteParticipant _getOrCreateRemoteParticipant(String sid, lk_models.ParticipantInfo? info) {
var participant = _participants[sid]; var participant = _participants[sid];
if (participant != null) { if (participant != null) {
@@ -224,16 +241,21 @@ class Room extends ChangeNotifier with ParticipantDelegate {
} }
void _handleICEConnected() { void _handleICEConnected() {
_connectCompleter?.complete(this); // _connectCompleter?.complete(this);
_connectCompleter = null; // _connectCompleter = null;
_state = RoomState.connected; _connectionState = RoomState.connected;
notifyListeners(); notifyListeners();
} }
Future<void> _handleDisconnect() async { Future<void> _handleDisconnect() async {
if (_state == RoomState.disconnected) { if (_connectionState == RoomState.disconnected) {
logger.fine('$objectId: _handleDisconnect() already disconnected');
return; return;
} }
// we need to flag room as disconnected immediately to avoid
// this method firing multiple times since the following code
// is being awaited
_connectionState = RoomState.disconnected;
for (final p in _participants.values) { for (final p in _participants.values) {
final tracks = List<TrackPublication>.from(p.tracks.values); final tracks = List<TrackPublication>.from(p.tracks.values);
@@ -248,7 +270,7 @@ class Room extends ChangeNotifier with ParticipantDelegate {
await _engine.close(); await _engine.close();
_participants.clear(); _participants.clear();
_activeSpeakers.clear(); _activeSpeakers.clear();
_state = RoomState.disconnected;
notifyListeners(); notifyListeners();
delegate?.onDisconnected(); delegate?.onDisconnected();
} }
@@ -340,17 +362,23 @@ class Room extends ChangeNotifier with ParticipantDelegate {
track?.muted = mute; track?.muted = mute;
} }
void _onTrackAdded(MediaStreamTrack track, MediaStream? stream, RTCRtpReceiver? receiver) { void _onTrackAdded(
rtc.MediaStreamTrack track,
rtc.MediaStream? stream,
rtc.RTCRtpReceiver? receiver,
) {
if (stream == null) { if (stream == null) {
// we need the stream to get the track's id // we need the stream to get the track's id
logger.severe('received track without mediastream'); logger.severe('received track without mediastream');
return; return;
} }
final parsed = _unpackStreamId(stream.id); final idParts = stream.id.split('|');
final trackSid = parsed.item2 ?? track.id;
final participant = _getOrCreateRemoteParticipant(parsed.item1, null); final participantSid = idParts[0];
final trackSid = idParts.elementAtOrNull(1) ?? track.id;
final participant = _getOrCreateRemoteParticipant(participantSid, null);
participant.addSubscribedMediaTrack(track, stream, trackSid); participant.addSubscribedMediaTrack(track, stream, trackSid);
} }
@@ -415,11 +443,3 @@ class Room extends ChangeNotifier with ParticipantDelegate {
delegate?.onTrackSubscriptionFailed(participant, sid, message); delegate?.onTrackSubscriptionFailed(participant, sid, message);
} }
} }
Tuple2<String, String?> _unpackStreamId(String streamId) {
var parts = streamId.split('|');
if (parts.length != 2) {
return Tuple2(parts[0], null);
}
return Tuple2(parts[0], parts[1]);
}
+326 -178
View File
@@ -1,28 +1,28 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'errors.dart'; import 'errors.dart';
import 'events.dart';
import 'extensions.dart'; import 'extensions.dart';
import 'logger.dart'; import 'logger.dart';
import 'managers/delay.dart';
import 'managers/event.dart';
import 'options.dart'; import 'options.dart';
import 'proto/livekit_models.pb.dart' as lk_models; import 'proto/livekit_models.pb.dart' as lk_models;
import 'proto/livekit_rtc.pb.dart' as lk_rtc; import 'proto/livekit_rtc.pb.dart' as lk_rtc;
import 'signal_client.dart'; import 'signal_client.dart';
import 'track/track.dart'; import 'track/track.dart';
import 'transport.dart'; import 'transport.dart';
import 'types.dart';
const lossyDataChannel = '_lossy';
const reliableDataChannel = '_reliable';
const connectionTimeout = Duration(seconds: 5);
const maxReconnectAttempts = 5;
const iceRestartTimeout = Duration(seconds: 10);
typedef GenericCallback = void Function(); typedef GenericCallback = void Function();
typedef TrackCallback = void Function( typedef TrackCallback = void Function(
MediaStreamTrack track, rtc.MediaStreamTrack track,
MediaStream? stream, rtc.MediaStream? stream,
RTCRtpReceiver? receiver, rtc.RTCRtpReceiver? receiver,
); );
typedef ParticipantUpdateCallback = void Function(List<lk_models.ParticipantInfo> participants); typedef ParticipantUpdateCallback = void Function(List<lk_models.ParticipantInfo> participants);
typedef ActiveSpeakerChangedCallback = void Function(List<lk_models.SpeakerInfo> speakers); typedef ActiveSpeakerChangedCallback = void Function(List<lk_models.SpeakerInfo> speakers);
@@ -31,25 +31,42 @@ typedef DataPacketCallback = void Function(
typedef RemoteMuteCallback = void Function(String sid, bool mute); typedef RemoteMuteCallback = void Function(String sid, bool mute);
class RTCEngine with SignalClientDelegate { class RTCEngine with SignalClientDelegate {
static const _lossyDCLabel = '_lossy';
static const _reliableDCLabel = '_reliable';
static const _maxReconnectAttempts = 5;
static const _maxICEConnectTimeout = Duration(seconds: 5);
static const _connectionTimeout = Duration(seconds: 5);
static const _iceRestartTimeout = Duration(seconds: 10);
final SignalClient client;
// config for RTCPeerConnection
final RTCConfiguration? rtcConfig;
PCTransport? publisher; PCTransport? publisher;
PCTransport? subscriber; PCTransport? subscriber;
SignalClient client; PCTransport? get primary => _subscriberPrimary ? subscriber : publisher;
// config for RTCPeerConnection
RTCConfiguration rtcConfig = RTCConfiguration(); // used for ice state notifications
CancelListenFunc? _primaryIceStateListener;
// data channels for packets // data channels for packets
RTCDataChannel? reliableDC; rtc.RTCDataChannel? reliableDC;
RTCDataChannel? lossyDC; rtc.RTCDataChannel? lossyDC;
bool iceConnected = false; bool iceConnected = false;
bool isReconnecting = false; bool isReconnecting = false;
bool isClosed = true; bool isClosed = true;
Map<String, Completer<lk_models.TrackInfo>> pendingTrackResolvers = {}; // true if publisher connection has already been established.
int reconnectAttempts = 0; // this is helpful to know if we need to restart ICE on the publisher connection
// to complete join request bool _hasPublished = false;
Completer<lk_rtc.JoinResponse>? joinCompleter;
// remember url and token for reconnect // remember url and token for reconnect
String? url; String? url;
String? token; String? token;
bool _subscriberPrimary = false;
// server-provided ice servers
List<lk_rtc.ICEServer> _providedIceServers = [];
// delegate methods // delegate methods
GenericCallback? onICEConnected; GenericCallback? onICEConnected;
TrackCallback? onTrack; TrackCallback? onTrack;
@@ -61,12 +78,29 @@ class RTCEngine with SignalClientDelegate {
GenericCallback? onReconnected; GenericCallback? onReconnected;
GenericCallback? onDisconnected; GenericCallback? onDisconnected;
RTCEngine(this.client, RTCConfiguration? rtcConfig) { //
if (rtcConfig != null) { // internal
this.rtcConfig = rtcConfig; //
} final Map<String, Completer<lk_models.TrackInfo>> _pendingTrackResolvers = {};
int _reconnectAttempts = 0;
// to complete join request
Completer<lk_rtc.JoinResponse>? _joinCompleter;
final events = EventsEmitter<EngineEvent>();
final delays = CancelableDelayManager();
RTCEngine(
this.client,
this.rtcConfig,
) {
client.delegate = this; client.delegate = this;
if (kDebugMode) {
events.listen((event) => logger.fine('[LISTENER] $objectId ${event.runtimeType}'));
events.on<EngineIceStateUpdatedEvent>(
(event) => logger.fine('[LISTENER] event is a EngineIceStateUpdatedEvent'));
}
} }
Future<lk_rtc.JoinResponse> join( Future<lk_rtc.JoinResponse> join(
@@ -78,22 +112,36 @@ class RTCEngine with SignalClientDelegate {
this.token = token; this.token = token;
final completer = Completer<lk_rtc.JoinResponse>(); final completer = Completer<lk_rtc.JoinResponse>();
joinCompleter = completer; _joinCompleter = completer;
await client.join(url, token, options: options); await client.join(url, token, options: options);
// if it's not complete after 5 seconds, fail // if it's not complete after 5 seconds, fail
Timer(connectionTimeout, () { Timer(_connectionTimeout, () {
joinCompleter?.completeError(ConnectError()); _joinCompleter?.completeError(ConnectException());
joinCompleter = null; _joinCompleter = null;
}); });
return completer.future; return completer.future;
} }
Future<void> close() async { Future<void> close() async {
logger.fine('${objectId} close()');
if (isClosed) {
logger.fine('${objectId} close() already closed');
return;
}
isClosed = true; isClosed = true;
// cancel events
await _primaryIceStateListener?.call();
_primaryIceStateListener = null;
await events.dispose();
// cancel all ongoing delays
await delays.dispose();
// PCTransport is responsible for disposing RTCPeerConnection // PCTransport is responsible for disposing RTCPeerConnection
await publisher?.dispose(); await publisher?.dispose();
publisher = null; publisher = null;
@@ -110,12 +158,12 @@ class RTCEngine with SignalClientDelegate {
required lk_models.TrackType kind, required lk_models.TrackType kind,
TrackDimension? dimension, TrackDimension? dimension,
}) async { }) async {
if (pendingTrackResolvers[cid] != null) { if (_pendingTrackResolvers[cid] != null) {
throw TrackPublishError('a track with the same CID has already been published'); throw TrackPublishException('a track with the same CID has already been published');
} }
final completer = Completer<lk_models.TrackInfo>(); final completer = Completer<lk_models.TrackInfo>();
pendingTrackResolvers[cid] = completer; _pendingTrackResolvers[cid] = completer;
client.sendAddTrack(cid: cid, name: name, type: kind, dimension: dimension); client.sendAddTrack(cid: cid, name: name, type: kind, dimension: dimension);
@@ -123,187 +171,277 @@ class RTCEngine with SignalClientDelegate {
} }
Future<void> negotiate({bool? iceRestart}) async { Future<void> negotiate({bool? iceRestart}) async {
final pub = publisher; if (publisher == null) {
if (pub == null) return; return;
final remoteDesc = await pub.getRemoteDescription();
// handle cases that we couldn't create a new offer due to a pending answer
// that's lost in transit
if (remoteDesc != null &&
pub.pc.signalingState == RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
await pub.pc.setRemoteDescription(remoteDesc);
} }
final constraints = <String, dynamic>{}; _hasPublished = true;
if (iceRestart != null && iceRestart) { publisher!.negotiate();
constraints['mandatory'] = { }
'IceRestart': true,
}; /* @internal */
Future<void> sendDataPacket(
lk_models.DataPacket packet,
) async {
// make sure we do have a data connection
await _ensurePublisherConnected();
final dcMessage = rtc.RTCDataChannelMessage.fromBinary(packet.writeToBuffer());
if (packet.kind == lk_models.DataPacket_Kind.LOSSY && lossyDC != null) {
await lossyDC?.send(dcMessage);
} else if (packet.kind == lk_models.DataPacket_Kind.RELIABLE && reliableDC != null) {
await reliableDC?.send(dcMessage);
} }
final offer = await pub.pc.createOffer(constraints); }
logger.fine('Created offer');
logger.finer('sdp: ${offer.sdp}'); Future<void> _ensurePublisherConnected() async {
await pub.pc.setLocalDescription(offer); logger.fine('ensurePublisherConnected()');
client.sendOffer(offer); if (!_subscriberPrimary) {
return;
}
if (publisher?.pc.iceConnectionState?.isConnected() == true) {
logger.warning('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: _maxICEConnectTimeout,
);
logger.fine('[PUBLISHER] connected');
} }
Future<void> reconnect() async { Future<void> reconnect() async {
if (isClosed) return; if (isClosed) {
logger.fine('$objectId reconnect() already closed');
return;
}
final url = this.url; final url = this.url;
final token = this.token; final token = this.token;
if (url == null || token == null) { if (url == null || token == null) {
throw ConnectError('could not reconnect without url and token'); throw ConnectException('could not reconnect without url and token');
} }
if (reconnectAttempts == 0) {
if (_reconnectAttempts == 0) {
onReconnecting?.call(); onReconnecting?.call();
events.emit(EngineReconnectingEvent());
} }
reconnectAttempts++; _reconnectAttempts++;
try { try {
isReconnecting = true; isReconnecting = true;
await client.reconnect(url, token); await client.reconnect(url, token);
final pub = publisher; if (publisher == null || subscriber == null) {
final sub = subscriber; throw UnexpectedStateException('publisher or subscribers is null');
if (pub == null || sub == null) {
throw UnexpectedConnectionState('publisher or subscribers is null');
} }
pub.restartingIce = true; subscriber!.restartingIce = true;
sub.restartingIce = true;
await negotiate(iceRestart: true); // await negotiate(iceRestart: true);
} catch (error) { 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: _iceRestartTimeout,
);
}
logger.fine('reconnect: success');
events.emit(EngineReconnectedEvent());
_reconnectAttempts = 0;
// don't catch and pass up any exception
} finally {
// always set reconnecting to false
isReconnecting = false; isReconnecting = false;
return Future.error(error);
} }
// wait for connectivity to change
final startTime = DateTime.now();
while (DateTime.now().difference(startTime) < iceRestartTimeout) {
if (iceConnected) {
isReconnecting = false;
return;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
isReconnecting = false;
throw ConnectError('could not reconnect ICE');
} }
Future<void> _configurePeerConnections() async { Future<void> _configurePeerConnections() async {
if (publisher != null) { if (publisher != null || subscriber != null) {
logger.warning('Already configured');
return; return;
} }
final pubPC = await createPeerConnection(rtcConfig.toMap()); RTCConfiguration? config;
publisher = PCTransport(pubPC); // use server-provided iceServers if not provided by user
final subPC = await createPeerConnection(rtcConfig.toMap()); if ((rtcConfig?.iceServers?.isEmpty ?? true) && _providedIceServers.isNotEmpty) {
subscriber = PCTransport(subPC); final iceServers = _providedIceServers.map((e) => e.toSDKType()).toList();
config = (rtcConfig ?? const RTCConfiguration()).copyWith(iceServers: iceServers);
}
pubPC.onIceCandidate = (RTCIceCandidate candidate) { publisher = await PCTransport.create(config);
subscriber = await PCTransport.create(config);
publisher?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) {
logger.fine('publisher onIceCandidate');
client.sendIceCandidate(candidate, lk_rtc.SignalTarget.PUBLISHER); client.sendIceCandidate(candidate, lk_rtc.SignalTarget.PUBLISHER);
}; };
subPC.onIceCandidate = (RTCIceCandidate candidate) {
subscriber?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) {
logger.fine('subscriber onIceCandidate');
client.sendIceCandidate(candidate, lk_rtc.SignalTarget.SUBSCRIBER); client.sendIceCandidate(candidate, lk_rtc.SignalTarget.SUBSCRIBER);
}; };
pubPC.onRenegotiationNeeded = () async { publisher?.onOffer = (offer) {
if (pubPC.iceConnectionState == null || logger.fine('publisher onOffer');
pubPC.iceConnectionState == RTCIceConnectionState.RTCIceConnectionStateNew) { client.sendOffer(offer);
return;
}
await negotiate();
}; };
pubPC.onIceConnectionState = (RTCIceConnectionState state) { // in subscriber primary mode, server side opens sub data channels.
if (publisher == null) { if (_subscriberPrimary) {
return; subscriber?.pc.onDataChannel = _onDataChannel;
} }
switch (state) {
case RTCIceConnectionState.RTCIceConnectionStateConnected: // logger.fine('subscriber.pc: ${subscriber?.pc}');
if (!iceConnected) { subscriber?.pc.onIceConnectionState = (state) {
iceConnected = true; //
if (isReconnecting) { events.emit(EngineSubscriberIceStateUpdatedEvent(
onReconnected?.call(); state: state,
} else { isPrimary: _subscriberPrimary,
onICEConnected?.call(); ));
} };
publisher?.pc.onIceConnectionState = (state) {
//
events.emit(EnginePublisherIceStateUpdatedEvent(
state: state,
isPrimary: !_subscriberPrimary,
));
};
_primaryIceStateListener ??= 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 (isReconnecting) {
onReconnected?.call();
} else {
onICEConnected?.call();
events.emit(EngineConnectedEvent());
} }
break; }
} else if (event.iceState == rtc.RTCIceConnectionState.RTCIceConnectionStateFailed) {
case RTCIceConnectionState.RTCIceConnectionStateFailed: // trigger reconnect sequence
if (iceConnected) {
iceConnected = false; iceConnected = false;
// trigger reconnect sequence _onDisconnected('peerconnection');
_handleDisconnect('peerconnection'); }
break;
default:
// do nothing
} }
});
subscriber?.pc.onTrack = (rtc.RTCTrackEvent event) {
onTrack?.call(event.track, event.streams.firstOrNull, event.receiver);
events.emit(EngineMediaTrackAddedEvent(
track: event.track,
stream: event.streams.firstOrNull,
receiver: event.receiver,
));
}; };
subPC.onTrack = (RTCTrackEvent event) { // data channels
onTrack?.call(event.track, event.streams.first, event.receiver); final lossyInit = rtc.RTCDataChannelInit()
}; ..binaryType = 'binary'
// create data channels
final lossyInit = RTCDataChannelInit()
..maxRetransmits = 1
..ordered = true ..ordered = true
..binaryType = 'binary'; ..maxRetransmits = 0;
lossyDC = await pubPC.createDataChannel(lossyDataChannel, lossyInit); lossyDC = await publisher?.pc.createDataChannel(_lossyDCLabel, lossyInit);
final reliableInit = RTCDataChannelInit() final reliableInit = rtc.RTCDataChannelInit()
..ordered = true ..binaryType = 'binary'
..maxRetransmits = 50 ..ordered = true;
..binaryType = 'binary'; reliableDC = await publisher?.pc.createDataChannel(_reliableDCLabel, reliableInit);
reliableDC = await pubPC.createDataChannel(reliableDataChannel, reliableInit);
lossyDC?.onMessage = _handleDataMessage; // also handle messages over the pub channel, for backwards compatibility
reliableDC?.onMessage = _handleDataMessage; lossyDC?.onMessage = _onDCMessage;
reliableDC?.onMessage = _onDCMessage;
} }
void _handleDataMessage(RTCDataChannelMessage message) { void _onDataChannel(rtc.RTCDataChannel dc) {
switch (dc.label) {
case _reliableDCLabel:
logger.fine('Server opened DC label: ${dc.label}');
reliableDC = dc;
reliableDC?.onMessage = _onDCMessage;
break;
case _lossyDCLabel:
logger.fine('Server opened DC label: ${dc.label}');
lossyDC = dc;
lossyDC?.onMessage = _onDCMessage;
break;
default:
logger.warning('Unknown DC label: ${dc.label}');
break;
}
}
void _onDCMessage(rtc.RTCDataChannelMessage message) {
// always expect binary // always expect binary
if (!message.isBinary) { if (!message.isBinary) {
logger.warning('Data message is not binary');
return; return;
} }
final dp = lk_models.DataPacket.fromBuffer(message.binary); final dp = lk_models.DataPacket.fromBuffer(message.binary);
switch (dp.whichValue()) { if (dp.whichValue() == lk_models.DataPacket_Value.speaker) {
case lk_models.DataPacket_Value.speaker: // Speaker packet
onActiveSpeakerUpdated?.call(dp.speaker.speakers); onActiveSpeakerUpdated?.call(dp.speaker.speakers);
break; events.emit(EngineSpeakersUpdateEvent(speakers: dp.speaker.speakers));
case lk_models.DataPacket_Value.user: } else if (dp.whichValue() == lk_models.DataPacket_Value.user) {
onDataMessage?.call(dp.user, dp.kind); // User packet
break; onDataMessage?.call(dp.user, dp.kind);
default: events.emit(EngineDataPacketReceivedEvent(
// do nothing packet: dp.user,
kind: dp.kind,
));
} }
} }
Future<void> _handleDisconnect(String reason) async { Future<void> _onDisconnected(String reason) async {
if (isClosed) return; if (isClosed) return;
logger.fine('disconnected $reason'); logger.fine('disconnected $reason');
if (reconnectAttempts >= maxReconnectAttempts) { if (_reconnectAttempts >= _maxReconnectAttempts) {
logger.info('could not connect after $reconnectAttempts, giving up'); logger.info('could not connect after $_reconnectAttempts, giving up');
await close(); await close();
onDisconnected?.call(); onDisconnected?.call();
events.emit(EngineDisconnectedEvent());
return; return;
} }
final delay = (reconnectAttempts * reconnectAttempts) * 300; final delay = Duration(milliseconds: (_reconnectAttempts * _reconnectAttempts) * 300);
Future.delayed(Duration(milliseconds: delay), () {
reconnect().then((_) { // if this instance is disposed, we probably don't want to continue any more
reconnectAttempts = 0; // so the whole block will be canceled from being executed
}).catchError((dynamic e) { await delays.waitFor(delay, ifNotCancelled: () async {
_handleDisconnect(reason); try {
}); await reconnect();
_reconnectAttempts = 0;
} catch (_) {
// doesn't need to be awaited
// ignore: unawaited_futures
_onDisconnected(reason);
}
}); });
} }
@@ -313,90 +451,100 @@ class RTCEngine with SignalClientDelegate {
Future<void> onConnected(lk_rtc.JoinResponse response) async { Future<void> onConnected(lk_rtc.JoinResponse response) async {
// create peer connections // create peer connections
isClosed = false; isClosed = false;
_subscriberPrimary = response.subscriberPrimary;
_providedIceServers = response.iceServers;
if (rtcConfig.iceServers == null && response.iceServers.isNotEmpty) { logger.fine('onConnected subscriberPrimary: ${_subscriberPrimary}, '
List<RTCIceServer> iceServers = []; 'serverVersion: ${response.serverVersion}, '
for (final item in response.iceServers) { 'iceServers: ${response.iceServers}');
final iceServer = RTCIceServer(urls: item.urls);
if (item.username.isNotEmpty) {
iceServer.username = item.username;
}
if (item.credential.isNotEmpty) {
iceServer.credential = item.credential;
}
iceServers.add(iceServer);
}
rtcConfig.iceServers = iceServers;
}
await _configurePeerConnections(); await _configurePeerConnections();
await negotiate(); if (!_subscriberPrimary) {
// for subscriberPrimary, we negotiate when necessary (lazy)
await negotiate();
}
joinCompleter?.complete(Future.value(response)); _joinCompleter?.complete(Future.value(response));
joinCompleter = null; _joinCompleter = null;
} }
@override @override
Future<void> onClose([String? reason]) async { Future<void> onClose([String? reason]) async {
await _handleDisconnect('signal'); await _onDisconnected('signal');
} }
@override @override
Future<void> onOffer(RTCSessionDescription sd) async { Future<void> onOffer(rtc.RTCSessionDescription sd) async {
final sub = subscriber; if (subscriber == null) {
if (sub == null) return; return;
}
await sub.setRemoteDescription(sd); logger.fine('received server offer(type: ${sd.type}, ${subscriber!.pc.signalingState})');
final answer = await sub.pc.createAnswer(); await subscriber!.setRemoteDescription(sd);
final answer = await subscriber!.pc.createAnswer();
logger.fine('Created answer'); logger.fine('Created answer');
logger.finer('sdp: ${answer.sdp}'); logger.finer('sdp: ${answer.sdp}');
await sub.pc.setLocalDescription(answer); await subscriber!.pc.setLocalDescription(answer);
client.sendAnswer(answer); client.sendAnswer(answer);
} }
@override @override
Future<void> onAnswer(RTCSessionDescription sd) async { Future<void> onAnswer(rtc.RTCSessionDescription sd) async {
if (publisher == null) return; if (publisher == null) {
logger.fine('Received answer'); return;
}
logger.fine('received answer (type: ${sd.type})');
logger.finer('sdp: ${sd.sdp}'); logger.finer('sdp: ${sd.sdp}');
await publisher!.setRemoteDescription(sd); await publisher!.setRemoteDescription(sd);
} }
@override @override
Future<void> onTrickle(RTCIceCandidate candidate, lk_rtc.SignalTarget target) async { Future<void> onTrickle(rtc.RTCIceCandidate candidate, lk_rtc.SignalTarget target) async {
if (publisher == null || subscriber == null) {
return;
}
logger.fine('got ICE candidate from peer');
if (target == lk_rtc.SignalTarget.SUBSCRIBER) { if (target == lk_rtc.SignalTarget.SUBSCRIBER) {
await subscriber?.addIceCandidate(candidate); await subscriber!.addIceCandidate(candidate);
} else if (target == lk_rtc.SignalTarget.PUBLISHER) { } else if (target == lk_rtc.SignalTarget.PUBLISHER) {
await publisher?.addIceCandidate(candidate); await publisher!.addIceCandidate(candidate);
} }
} }
@override @override
Future<void> onParticipantUpdate(List<lk_models.ParticipantInfo> updates) async { Future<void> onParticipantUpdate(List<lk_models.ParticipantInfo> updates) async {
onParticipantUpdated?.call(updates); onParticipantUpdated?.call(updates);
events.emit(EngineParticipantUpdateEvent(participants: updates));
} }
@override @override
Future<void> onLocalTrackPublished(lk_rtc.TrackPublishedResponse response) async { Future<void> onLocalTrackPublished(lk_rtc.TrackPublishedResponse response) async {
final completer = pendingTrackResolvers.remove(response.cid); final completer = _pendingTrackResolvers.remove(response.cid);
completer?.complete(Future.value(response.track)); completer?.complete(Future.value(response.track));
} }
@override @override
Future<void> onActiveSpeakersChanged(List<lk_models.SpeakerInfo> speakers) async { Future<void> onActiveSpeakersChanged(List<lk_models.SpeakerInfo> speakers) async {
onActiveSpeakerUpdated?.call(speakers); onActiveSpeakerUpdated?.call(speakers);
events.emit(EngineSpeakersUpdateEvent(speakers: speakers));
} }
@override @override
Future<void> onLeave(lk_rtc.LeaveRequest req) async { Future<void> onLeave(lk_rtc.LeaveRequest req) async {
await close(); await close();
onDisconnected?.call(); onDisconnected?.call();
events.emit(EngineDisconnectedEvent());
} }
@override @override
Future<void> onMuteTrack(lk_rtc.MuteTrackRequest req) async { Future<void> onMuteTrack(lk_rtc.MuteTrackRequest req) async {
onRemoteMute?.call(req.sid, req.muted); onRemoteMute?.call(req.sid, req.muted);
events.emit(EngineRemoteMuteChangedEvent(
sid: req.sid,
muted: req.muted,
));
} }
} }
+36 -80
View File
@@ -2,17 +2,19 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:livekit_client/src/ws/interface.dart';
import 'package:synchronized/synchronized.dart' as sync; import 'package:synchronized/synchronized.dart' as sync;
import 'errors.dart'; import 'errors.dart';
import 'extensions.dart';
import 'logger.dart'; import 'logger.dart';
import 'options.dart'; import 'options.dart';
import 'proto/livekit_models.pb.dart' as lk_models; import 'proto/livekit_models.pb.dart' as lk_models;
import 'proto/livekit_rtc.pb.dart' as lk_rtc; import 'proto/livekit_rtc.pb.dart' as lk_rtc;
import 'track/track.dart'; import 'track/track.dart';
import 'utils.dart';
import 'ws/interface.dart';
mixin SignalClientDelegate { mixin SignalClientDelegate {
// initial connection established // initial connection established
@@ -20,11 +22,11 @@ mixin SignalClientDelegate {
// websocket has closed // websocket has closed
Future<void> onClose([String? reason]); Future<void> onClose([String? reason]);
// when a server offer is received // when a server offer is received
Future<void> onOffer(RTCSessionDescription sd); Future<void> onOffer(rtc.RTCSessionDescription sd);
// when an answer from server is received // when an answer from server is received
Future<void> onAnswer(RTCSessionDescription sd); Future<void> onAnswer(rtc.RTCSessionDescription sd);
// when server has a new ICE candidate // when server has a new ICE candidate
Future<void> onTrickle(RTCIceCandidate candidate, lk_rtc.SignalTarget target); Future<void> onTrickle(rtc.RTCIceCandidate candidate, lk_rtc.SignalTarget target);
// participant has changed // participant has changed
Future<void> onParticipantUpdate(List<lk_models.ParticipantInfo> updates); Future<void> onParticipantUpdate(List<lk_models.ParticipantInfo> updates);
// when a track has been added successfully // when a track has been added successfully
@@ -37,48 +39,20 @@ mixin SignalClientDelegate {
Future<void> onMuteTrack(lk_rtc.MuteTrackRequest req); Future<void> onMuteTrack(lk_rtc.MuteTrackRequest req);
} }
extension LKUriExt on Uri {
bool get isSecureScheme => ['https', 'wss'].contains(scheme);
}
class SignalClient { class SignalClient {
static const protocolVersion = 2;
final _lock = sync.Lock(); final _lock = sync.Lock();
ProtocolVersion protocol;
SignalClientDelegate? delegate; SignalClientDelegate? delegate;
bool _connected = false; bool _connected = false;
LKWebSocket? _ws; LiveKitWebSocket? _ws;
SignalClient(); SignalClient({
this.protocol = ProtocolVersion.protocol3,
});
bool get connected => _connected; bool get connected => _connected;
Uri _buildUri(
String uriOrString, {
required String token,
ConnectOptions? options,
bool reconnect = false,
bool validate = false,
bool forceSecure = false,
}) {
final Uri uri = Uri.parse(uriOrString);
final useSecure = uri.isSecureScheme || forceSecure;
final httpScheme = useSecure ? 'https' : 'http';
final wsScheme = useSecure ? 'wss' : 'ws';
return uri.replace(
scheme: validate ? httpScheme : wsScheme,
path: validate ? 'validate' : 'rtc',
queryParameters: <String, String>{
'access_token': token,
if (options != null) 'auto_subscribe': options.autoSubscribe ? '1' : '0',
if (reconnect) 'reconnect': '1',
'protocol': protocolVersion.toString(),
},
);
}
Future<void> join( Future<void> join(
String uriString, String uriString,
String token, { String token, {
@@ -87,16 +61,17 @@ class SignalClient {
// Create default options if null // Create default options if null
options ??= const ConnectOptions(); options ??= const ConnectOptions();
final rtcUri = _buildUri( final rtcUri = Utils.buildUri(
uriString, uriString,
token: token, token: token,
options: options, options: options,
protocol: protocol,
); );
try { try {
_ws = await LKWebSocket.connect( _ws = await LiveKitWebSocket.connect(
rtcUri, rtcUri,
LKWebSocketOptions( WebSocketOptions(
onData: _onSocketData, onData: _onSocketData,
onDispose: _onSocketDone, onDispose: _onSocketDone,
onError: _handleError, onError: _handleError,
@@ -104,24 +79,25 @@ class SignalClient {
); );
} catch (socketError) { } catch (socketError) {
// Re-build same uri for validate mode // Re-build same uri for validate mode
final validateUri = _buildUri( final validateUri = Utils.buildUri(
uriString, uriString,
token: token, token: token,
options: options, options: options,
validate: true, validate: true,
forceSecure: rtcUri.isSecureScheme, forceSecure: rtcUri.isSecureScheme,
protocol: protocol,
); );
// Attempt Validation // Attempt Validation
try { try {
final validateResponse = await http.get(validateUri); final validateResponse = await http.get(validateUri);
if (validateResponse.statusCode != 200) throw ConnectError(validateResponse.body); if (validateResponse.statusCode != 200) throw ConnectException(validateResponse.body);
throw ConnectError(); throw ConnectException();
} catch (error) { } catch (error) {
// Pass it up if it's already a `ConnectError` // Pass it up if it's already a `ConnectError`
if (error is ConnectError) rethrow; if (error is ConnectException) rethrow;
// HTTP doesn't work either // HTTP doesn't work either
throw ConnectError(); throw ConnectException();
} }
} }
} }
@@ -134,15 +110,16 @@ class SignalClient {
_ws?.dispose(); _ws?.dispose();
_ws = null; _ws = null;
final rtcUri = _buildUri( final rtcUri = Utils.buildUri(
uriString, uriString,
token: token, token: token,
reconnect: true, reconnect: true,
protocol: protocol,
); );
_ws = await LKWebSocket.connect( _ws = await LiveKitWebSocket.connect(
rtcUri, rtcUri,
LKWebSocketOptions( WebSocketOptions(
onData: _onSocketData, onData: _onSocketData,
onDispose: _onSocketDone, onDispose: _onSocketDone,
onError: _handleError, onError: _handleError,
@@ -157,18 +134,18 @@ class SignalClient {
_ws?.dispose(); _ws?.dispose();
} }
void sendOffer(RTCSessionDescription offer) => _sendRequest(lk_rtc.SignalRequest( void sendOffer(rtc.RTCSessionDescription offer) => _sendRequest(lk_rtc.SignalRequest(
offer: fromRTCSessionDescription(offer), offer: offer.toSDKType(),
)); ));
void sendAnswer(RTCSessionDescription answer) => _sendRequest(lk_rtc.SignalRequest( void sendAnswer(rtc.RTCSessionDescription answer) => _sendRequest(lk_rtc.SignalRequest(
answer: fromRTCSessionDescription(answer), answer: answer.toSDKType(),
)); ));
void sendIceCandidate(RTCIceCandidate candidate, lk_rtc.SignalTarget target) => _sendRequest( void sendIceCandidate(rtc.RTCIceCandidate candidate, lk_rtc.SignalTarget target) => _sendRequest(
lk_rtc.SignalRequest( lk_rtc.SignalRequest(
trickle: lk_rtc.TrickleRequest( trickle: lk_rtc.TrickleRequest(
candidateInit: fromRTCIceCandidate(candidate), candidateInit: candidate.toJson(),
target: target, target: target,
), ),
), ),
@@ -249,14 +226,14 @@ class SignalClient {
} }
break; break;
case lk_rtc.SignalResponse_Message.answer: case lk_rtc.SignalResponse_Message.answer:
await delegate?.onAnswer(toRTCSessionDescription(msg.answer)); await delegate?.onAnswer(msg.answer.toSDKType());
break; break;
case lk_rtc.SignalResponse_Message.offer: case lk_rtc.SignalResponse_Message.offer:
await delegate?.onOffer(toRTCSessionDescription(msg.offer)); await delegate?.onOffer(msg.offer.toSDKType());
break; break;
case lk_rtc.SignalResponse_Message.trickle: case lk_rtc.SignalResponse_Message.trickle:
await delegate?.onTrickle( await delegate?.onTrickle(
toRTCIceCandidate(msg.trickle.candidateInit), RTCIceCandidateExt.fromJson(msg.trickle.candidateInit),
msg.trickle.target, msg.trickle.target,
); );
break; break;
@@ -292,24 +269,3 @@ class SignalClient {
delegate?.onClose(); delegate?.onClose();
} }
} }
RTCSessionDescription toRTCSessionDescription(lk_rtc.SessionDescription sd) {
return RTCSessionDescription(sd.sdp, sd.type);
}
lk_rtc.SessionDescription fromRTCSessionDescription(RTCSessionDescription rsd) {
return lk_rtc.SessionDescription(type: rsd.type, sdp: rsd.sdp);
}
RTCIceCandidate toRTCIceCandidate(String candidateInit) {
final candInit = json.decode(candidateInit) as Map<String, dynamic>;
return RTCIceCandidate(
candInit['candidate'] as String?,
candInit['sdpMid'] as String?,
candInit['sdpMLineIndex'] as int?,
);
}
String fromRTCIceCandidate(RTCIceCandidate candidate) {
return json.encode(candidate.toMap());
}
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
void startAudio(String id, MediaStreamTrack stream) { void startAudio(String id, rtc.MediaStreamTrack stream) {
// do nothing // do nothing
} }
+2 -2
View File
@@ -1,14 +1,14 @@
// ignore: avoid_web_libraries_in_flutter // ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html; import 'dart:html' as html;
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
// ignore: implementation_imports // ignore: implementation_imports
import 'package:flutter_webrtc/src/web/media_stream_track_impl.dart'; import 'package:flutter_webrtc/src/web/media_stream_track_impl.dart';
const audioContainerId = 'livekit_audio_container'; const audioContainerId = 'livekit_audio_container';
const audioPrefix = 'livekit_audio_'; const audioPrefix = 'livekit_audio_';
void startAudio(String id, MediaStreamTrack track) { void startAudio(String id, rtc.MediaStreamTrack track) {
if (track is! MediaStreamTrackWeb) { if (track is! MediaStreamTrackWeb) {
return; return;
} }
+3 -3
View File
@@ -1,4 +1,4 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
import '_audio_api.dart' if (dart.library.html) '_audio_html.dart' as audio; import '_audio_api.dart' if (dart.library.html) '_audio_html.dart' as audio;
@@ -6,9 +6,9 @@ import 'local_audio_track.dart';
import 'track.dart'; import 'track.dart';
class AudioTrack extends Track { class AudioTrack extends Track {
MediaStream? mediaStream; rtc.MediaStream? mediaStream;
AudioTrack(String name, MediaStreamTrack track, this.mediaStream) AudioTrack(String name, rtc.MediaStreamTrack track, this.mediaStream)
: super(lk_models.TrackType.AUDIO, name, track); : super(lk_models.TrackType.AUDIO, name, track);
/// Start playing audio track. On web platform, create an audio element and /// Start playing audio track. On web platform, create an audio element and
+5 -5
View File
@@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../errors.dart'; import '../errors.dart';
import 'audio_track.dart'; import 'audio_track.dart';
@@ -9,19 +9,19 @@ import 'options.dart';
class LocalAudioTrack extends AudioTrack { class LocalAudioTrack extends AudioTrack {
LocalAudioTrack( LocalAudioTrack(
String name, String name,
MediaStreamTrack track, rtc.MediaStreamTrack track,
MediaStream stream, rtc.MediaStream stream,
) : super(name, track, stream); ) : super(name, track, stream);
/// Creates a new audio track from the default audio input device. /// Creates a new audio track from the default audio input device.
static Future<LocalAudioTrack> create([LocalAudioTrackOptions? options]) async { static Future<LocalAudioTrack> create([LocalAudioTrackOptions? options]) async {
// try { // try {
final stream = await navigator.mediaDevices.getUserMedia(<String, dynamic>{ final stream = await rtc.navigator.mediaDevices.getUserMedia(<String, dynamic>{
'audio': true, 'audio': true,
'video': false, 'video': false,
}); });
if (stream.getAudioTracks().isEmpty) throw TrackCreateError(); if (stream.getAudioTracks().isEmpty) throw TrackCreateException();
return LocalAudioTrack('', stream.getAudioTracks().first, stream); return LocalAudioTrack('', stream.getAudioTracks().first, stream);
} }
+1 -2
View File
@@ -1,5 +1,4 @@
import 'package:livekit_client/src/logger.dart'; import '../logger.dart';
import '../participant/local_participant.dart'; import '../participant/local_participant.dart';
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
import 'track.dart'; import 'track.dart';
+11 -11
View File
@@ -1,4 +1,4 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../errors.dart'; import '../errors.dart';
import '../logger.dart'; import '../logger.dart';
@@ -19,19 +19,19 @@ class LocalVideoTrack extends VideoTrack {
// //
LocalVideoTrack._( LocalVideoTrack._(
String name, String name,
MediaStreamTrack mediaTrack, rtc.MediaStreamTrack mediaTrack,
MediaStream stream, rtc.MediaStream stream,
this.currentOptions, this.currentOptions,
) : super(name, mediaTrack, stream); ) : super(name, mediaTrack, stream);
RTCRtpSender? get sender => transceiver?.sender; rtc.RTCRtpSender? get sender => transceiver?.sender;
/// Restarts the track with new options. This is useful when switching between /// Restarts the track with new options. This is useful when switching between
/// front and back cameras. /// front and back cameras.
Future<void> restartTrack([ Future<void> restartTrack([
LocalVideoTrackOptions? options, LocalVideoTrackOptions? options,
]) async { ]) async {
if (sender == null) throw TrackCreateError('could not restart track'); if (sender == null) throw TrackCreateException('could not restart track');
if (options != null && currentOptions.runtimeType != options.runtimeType) { if (options != null && currentOptions.runtimeType != options.runtimeType) {
throw Exception('options must be a ${currentOptions.runtimeType}'); throw Exception('options must be a ${currentOptions.runtimeType}');
} }
@@ -73,7 +73,7 @@ class LocalVideoTrack extends VideoTrack {
); );
} }
static Future<MediaStream> _createStream( static Future<rtc.MediaStream> _createStream(
LocalVideoTrackOptions options, LocalVideoTrackOptions options,
) async { ) async {
final constraints = <String, dynamic>{ final constraints = <String, dynamic>{
@@ -81,15 +81,15 @@ class LocalVideoTrack extends VideoTrack {
'video': options.toMediaConstraintsMap(), 'video': options.toMediaConstraintsMap(),
}; };
final MediaStream stream; final rtc.MediaStream stream;
if (options is ScreenTrackOptions) { if (options is ScreenTrackOptions) {
stream = await navigator.mediaDevices.getDisplayMedia(constraints); stream = await rtc.navigator.mediaDevices.getDisplayMedia(constraints);
} else { } else {
// options is CameraVideoTrackOptions // options is CameraVideoTrackOptions
stream = await navigator.mediaDevices.getUserMedia(constraints); stream = await rtc.navigator.mediaDevices.getUserMedia(constraints);
} }
if (stream.getVideoTracks().isEmpty) throw TrackCreateError(); if (stream.getVideoTracks().isEmpty) throw TrackCreateException();
return stream; return stream;
} }
} }
@@ -97,7 +97,7 @@ class LocalVideoTrack extends VideoTrack {
// //
// Convenience extensions // Convenience extensions
// //
extension LKLocalVideoTrackExt on LocalVideoTrack { extension LocalVideoTrackExt on LocalVideoTrack {
// Calls restartTrack under the hood // Calls restartTrack under the hood
Future<void> setCameraPosition(CameraPosition position) async { Future<void> setCameraPosition(CameraPosition position) async {
final options = currentOptions; final options = currentOptions;
+4 -4
View File
@@ -1,4 +1,4 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
enum LocalVideoTrackType { enum LocalVideoTrackType {
camera, camera,
@@ -10,7 +10,7 @@ enum CameraPosition {
back, back,
} }
extension LKCameraPositionExt on CameraPosition { extension CameraPositionExt on CameraPosition {
CameraPosition swap() => { CameraPosition swap() => {
CameraPosition.front: CameraPosition.back, CameraPosition.front: CameraPosition.back,
CameraPosition.back: CameraPosition.front, CameraPosition.back: CameraPosition.front,
@@ -74,12 +74,12 @@ class VideoEncoding {
} }
extension VideoEncodingExt on VideoEncoding { extension VideoEncodingExt on VideoEncoding {
RTCRtpEncoding toRTCRtpEncoding({ rtc.RTCRtpEncoding toRTCRtpEncoding({
String? rid, String? rid,
double? scaleResolutionDownBy = 1.0, double? scaleResolutionDownBy = 1.0,
int? numTemporalLayers, int? numTemporalLayers,
}) => }) =>
RTCRtpEncoding( rtc.RTCRtpEncoding(
rid: rid, rid: rid,
scaleResolutionDownBy: scaleResolutionDownBy, scaleResolutionDownBy: scaleResolutionDownBy,
maxFramerate: maxFramerate, maxFramerate: maxFramerate,
+7 -7
View File
@@ -1,4 +1,4 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
@@ -17,24 +17,24 @@ class Track {
String name; String name;
lk_models.TrackType kind; lk_models.TrackType kind;
MediaStreamTrack mediaStreamTrack; rtc.MediaStreamTrack mediaStreamTrack;
String? sid; String? sid;
RTCRtpTransceiver? transceiver; rtc.RTCRtpTransceiver? transceiver;
String? _cid; String? _cid;
Track(this.kind, this.name, this.mediaStreamTrack); Track(this.kind, this.name, this.mediaStreamTrack);
bool get muted => mediaStreamTrack.muted == null ? false : mediaStreamTrack.muted!; bool get muted => mediaStreamTrack.muted == null ? false : mediaStreamTrack.muted!;
RTCRtpMediaType get mediaType { rtc.RTCRtpMediaType get mediaType {
switch (kind) { switch (kind) {
case lk_models.TrackType.AUDIO: case lk_models.TrackType.AUDIO:
return RTCRtpMediaType.RTCRtpMediaTypeAudio; return rtc.RTCRtpMediaType.RTCRtpMediaTypeAudio;
case lk_models.TrackType.VIDEO: case lk_models.TrackType.VIDEO:
return RTCRtpMediaType.RTCRtpMediaTypeVideo; return rtc.RTCRtpMediaType.RTCRtpMediaTypeVideo;
// this should never happen // this should never happen
default: default:
return RTCRtpMediaType.RTCRtpMediaTypeAudio; return rtc.RTCRtpMediaType.RTCRtpMediaTypeAudio;
} }
} }
+5 -5
View File
@@ -1,17 +1,17 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
import 'track.dart'; import 'track.dart';
/// A video track will notify when its mediaTrack has changed. /// A video track will notify when its mediaTrack has changed.
class VideoTrack extends Track with ChangeNotifier { class VideoTrack extends Track with ChangeNotifier {
MediaStream _mediaStream; rtc.MediaStream _mediaStream;
VideoTrack( VideoTrack(
String name, String name,
MediaStreamTrack mediaTrack, rtc.MediaStreamTrack mediaTrack,
this._mediaStream, this._mediaStream,
) : super( ) : super(
lk_models.TrackType.VIDEO, lk_models.TrackType.VIDEO,
@@ -19,11 +19,11 @@ class VideoTrack extends Track with ChangeNotifier {
mediaTrack, mediaTrack,
); );
MediaStream get mediaStream => _mediaStream; rtc.MediaStream get mediaStream => _mediaStream;
/// internal use /// internal use
/// {@nodoc} /// {@nodoc}
void setMediaStream(MediaStream stream) { void setMediaStream(rtc.MediaStream stream) {
_mediaStream = stream; _mediaStream = stream;
notifyListeners(); notifyListeners();
} }
+85 -13
View File
@@ -1,49 +1,120 @@
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'logger.dart'; import 'logger.dart';
import 'types.dart';
import 'utils.dart';
import 'extensions.dart';
typedef PCTransportOnOffer = void Function(rtc.RTCSessionDescription offer);
/// a wrapper around PeerConnection /// a wrapper around PeerConnection
class PCTransport { class PCTransport {
final RTCPeerConnection pc; final rtc.RTCPeerConnection pc;
final List<RTCIceCandidate> _pendingCandidates = []; final List<rtc.RTCIceCandidate> _pendingCandidates = [];
bool restartingIce = false; bool restartingIce = false;
bool renegotiate = false;
PCTransportOnOffer? onOffer;
Function? _cancelDebounce;
PCTransport(this.pc); // private constructor
PCTransport._(this.pc);
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(
() => createAndSendOffer(),
cancelFunc: (f) => _cancelDebounce = f,
wait: const Duration(milliseconds: 100),
);
Future<void> dispose() async { Future<void> dispose() async {
logger.fine('${objectId} dispose()');
// Ensure debounce won't fire
_cancelDebounce?.call();
_cancelDebounce = null;
// Ensure callbacks won't fire any more // Ensure callbacks won't fire any more
pc.onRenegotiationNeeded = null; pc.onRenegotiationNeeded = null;
pc.onIceCandidate = null; pc.onIceCandidate = null;
pc.onIceConnectionState = null; pc.onIceConnectionState = null;
pc.onTrack = null; pc.onTrack = null;
List<RTCRtpSender> senders = []; // Remove all senders
List<rtc.RTCRtpSender> senders = [];
try { try {
senders = await pc.getSenders(); senders = await pc.getSenders();
} catch (_) {} } catch (_) {
logger.warning('getSenders() failed with error: $_');
}
for (final e in senders) { for (final e in senders) {
try { try {
await pc.removeTrack(e); await pc.removeTrack(e);
} catch (_) {} } catch (_) {
logger.warning('removeTrack() failed with error: $_');
}
} }
await pc.close(); await pc.close();
await pc.dispose(); await pc.dispose();
} }
Future<void> setRemoteDescription(RTCSessionDescription sd) async { Future<void> setRemoteDescription(rtc.RTCSessionDescription sd) async {
await pc.setRemoteDescription(sd); await pc.setRemoteDescription(sd);
await Future.forEach<RTCIceCandidate>(_pendingCandidates, (candidate) async { for (final candidate in _pendingCandidates) {
await pc.addCandidate(candidate); await pc.addCandidate(candidate);
}); }
_pendingCandidates.clear(); _pendingCandidates.clear();
restartingIce = false; restartingIce = false;
if (renegotiate) {
renegotiate = false;
await createAndSendOffer(); // await or un-awaited ?
}
} }
Future<void> addIceCandidate(RTCIceCandidate candidate) async { Future<void> createAndSendOffer([RTCOfferOptions? options]) async {
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 {
final desc = await getRemoteDescription(); final desc = await getRemoteDescription();
if (desc != null && !restartingIce) { if (desc != null && !restartingIce) {
@@ -54,15 +125,16 @@ class PCTransport {
_pendingCandidates.add(candidate); _pendingCandidates.add(candidate);
} }
Future<RTCSessionDescription?> getRemoteDescription() async { Future<rtc.RTCSessionDescription?> getRemoteDescription() async {
// Checking agains null doesn't work as intended // Checking agains null doesn't work as intended
// if (pc.iceConnectionState == null) return null; // if (pc.iceConnectionState == null) return null;
try { try {
final result = await pc.getRemoteDescription(); final result = await pc.getRemoteDescription();
logger.fine('pc.getRemoteDescription $result'); logger.fine('pc.getRemoteDescription $result');
return result; return result;
} catch (_) { } catch (_) {
logger.warning('pc.getRemoteDescription did throw: $_'); logger.warning('pc.getRemoteDescription failed with error: $_');
} }
} }
} }
+91
View File
@@ -0,0 +1,91 @@
//
// LiveKit
//
import 'package:flutter/material.dart';
import 'extensions.dart';
typedef CancelListenFunc = Function();
enum Reliability {
reliable,
lossy,
}
enum RTCIceTransportPolicy {
all,
relay,
}
@immutable
class RTCOfferOptions {
final bool iceRestart;
const RTCOfferOptions({
this.iceRestart = false,
});
Map<String, dynamic> toMap() => <String, dynamic>{
if (iceRestart) 'iceRestart': true,
};
}
@immutable
class RTCConfiguration {
final int? iceCandidatePoolSize;
final List<RTCIceServer>? iceServers;
final RTCIceTransportPolicy? iceTransportPolicy;
const RTCConfiguration({
this.iceCandidatePoolSize,
this.iceServers,
this.iceTransportPolicy,
});
Map<String, dynamic> toMap() {
final iceServersMap = <Map<String, dynamic>>[
if (iceServers != null)
for (final e in iceServers!) e.toMap()
];
return <String, dynamic>{
// only supports unified plan
'sdpSemantics': 'unified-plan',
if (iceServersMap.isNotEmpty) 'iceServers': iceServersMap,
if (iceCandidatePoolSize != null) 'iceCandidatePoolSize': iceCandidatePoolSize,
if (iceTransportPolicy != null) 'iceTransportPolicy': iceTransportPolicy!.toStringValue(),
};
}
// Returns new options with updated properties
RTCConfiguration copyWith({
int? iceCandidatePoolSize,
List<RTCIceServer>? iceServers,
RTCIceTransportPolicy? iceTransportPolicy,
}) =>
RTCConfiguration(
iceCandidatePoolSize: iceCandidatePoolSize ?? this.iceCandidatePoolSize,
iceServers: iceServers ?? this.iceServers,
iceTransportPolicy: iceTransportPolicy ?? this.iceTransportPolicy,
);
}
@immutable
class RTCIceServer {
final List<String>? urls;
final String? username;
final String? credential;
const RTCIceServer({
this.urls,
this.username,
this.credential,
});
Map<String, dynamic> toMap() => <String, dynamic>{
if (urls?.isNotEmpty ?? false) 'urls': urls,
if (username?.isNotEmpty ?? false) 'username': username,
if (credential?.isNotEmpty ?? false) 'credential': credential,
};
}
+67 -3
View File
@@ -2,12 +2,58 @@
// //
// //
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'options.dart'; import 'options.dart';
import 'track/options.dart'; import 'track/options.dart';
enum ProtocolVersion {
protocol2,
protocol3,
}
extension ProtocolVersionExt on ProtocolVersion {
String toStringValue() => {
ProtocolVersion.protocol2: '2',
ProtocolVersion.protocol3: '3',
}[this]!;
}
extension UriExt on Uri {
bool get isSecureScheme => ['https', 'wss'].contains(scheme);
}
// Collection of state-less static methods
class Utils { class Utils {
static Uri buildUri(
String uriString, {
required String token,
ConnectOptions? options,
bool reconnect = false,
bool validate = false,
bool forceSecure = false,
required ProtocolVersion protocol,
}) {
final Uri uri = Uri.parse(uriString);
final useSecure = uri.isSecureScheme || forceSecure;
final httpScheme = useSecure ? 'https' : 'http';
final wsScheme = useSecure ? 'wss' : 'ws';
return uri.replace(
scheme: validate ? httpScheme : wsScheme,
path: validate ? 'validate' : 'rtc',
queryParameters: <String, String>{
'access_token': token,
if (options != null) 'auto_subscribe': options.autoSubscribe ? '1' : '0',
if (reconnect) 'reconnect': '1',
'protocol': protocol.toStringValue(),
},
);
}
static List<VideoParameters> _presetsForResolution( static List<VideoParameters> _presetsForResolution(
int width, int width,
int height, int height,
@@ -31,7 +77,7 @@ class Utils {
return result; return result;
} }
static List<RTCRtpEncoding>? computeVideoEncodings({ static List<rtc.RTCRtpEncoding>? computeVideoEncodings({
int? width, int? width,
int? height, int? height,
TrackPublishOptions? options, TrackPublishOptions? options,
@@ -68,7 +114,7 @@ class Utils {
), ),
// if resolution is high enough, we would send both h and q res.. // if resolution is high enough, we would send both h and q res..
// otherwise only send h // otherwise only send h
if (height * 0.7 >= midPreset.height) ...[ if (width >= 960) ...[
midPreset.encoding.toRTCRtpEncoding( midPreset.encoding.toRTCRtpEncoding(
rid: 'h', rid: 'h',
scaleResolutionDownBy: height / midPreset.height, scaleResolutionDownBy: height / midPreset.height,
@@ -84,4 +130,22 @@ class Utils {
), ),
]; ];
} }
// makes a debounce func
static Function createDebounceFunc(
Function f, {
Function(Function)? cancelFunc,
required Duration wait,
}) {
Timer? t;
return () {
t?.cancel();
t = Timer(wait, () {
t = null;
f();
});
// pass back the cancel method so we can cancel it when no longer needed
cancelFunc?.call(t!.cancel);
};
}
} }
+7 -7
View File
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../track/local_video_track.dart'; import '../track/local_video_track.dart';
import '../track/video_track.dart'; import '../track/video_track.dart';
@@ -8,13 +8,13 @@ import '../track/video_track.dart';
/// Widget that renders a [VideoTrack]. /// Widget that renders a [VideoTrack].
class VideoTrackRenderer extends StatefulWidget { class VideoTrackRenderer extends StatefulWidget {
final VideoTrack track; final VideoTrack track;
final RTCVideoRenderer renderer; final rtc.RTCVideoRenderer renderer;
final RTCVideoViewObjectFit fit; final rtc.RTCVideoViewObjectFit fit;
VideoTrackRenderer( VideoTrackRenderer(
this.track, { this.track, {
this.fit = RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, this.fit = rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
}) : renderer = RTCVideoRenderer(), }) : renderer = rtc.RTCVideoRenderer(),
super(key: ValueKey(track.sid)); super(key: ValueKey(track.sid));
@override @override
@@ -22,7 +22,7 @@ class VideoTrackRenderer extends StatefulWidget {
} }
class _VideoTrackRendererState extends State<VideoTrackRenderer> { class _VideoTrackRendererState extends State<VideoTrackRenderer> {
final _renderer = RTCVideoRenderer(); final _renderer = rtc.RTCVideoRenderer();
@override @override
void initState() { void initState() {
@@ -61,7 +61,7 @@ class _VideoTrackRendererState extends State<VideoTrackRenderer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isLocal = widget.track is LocalVideoTrack; final isLocal = widget.track is LocalVideoTrack;
return RTCVideoView( return rtc.RTCVideoView(
_renderer, _renderer,
mirror: isLocal, mirror: isLocal,
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
+17 -17
View File
@@ -1,41 +1,41 @@
import 'platform/io.dart' if (dart.library.html) 'platform/web.dart'; import 'platform/io.dart' if (dart.library.html) 'platform/web.dart';
class LKWebSocketError implements Exception { class WebSocketException implements Exception {
final int code; final int code;
const LKWebSocketError._(this.code); const WebSocketException._(this.code);
static LKWebSocketError unknown() => const LKWebSocketError._(0); static WebSocketException unknown() => const WebSocketException._(0);
static LKWebSocketError connect() => const LKWebSocketError._(1); static WebSocketException connect() => const WebSocketException._(1);
@override @override
String toString() => { String toString() => {
LKWebSocketError.unknown(): 'Unknown error', WebSocketException.unknown(): 'Unknown error',
LKWebSocketError.connect(): 'Failed to connect', WebSocketException.connect(): 'Failed to connect',
}[this]!; }[this]!;
} }
typedef LKWebSocketOnData = Function(dynamic data); typedef WebSocketOnData = Function(dynamic data);
typedef LKWebSocketOnError = Function(dynamic error); typedef WebSocketOnError = Function(dynamic error);
typedef LKWebSocketOnDispose = Function(); typedef WebSocketOnDispose = Function();
class LKWebSocketOptions { class WebSocketOptions {
final LKWebSocketOnData? onData; final WebSocketOnData? onData;
final LKWebSocketOnError? onError; final WebSocketOnError? onError;
final LKWebSocketOnDispose? onDispose; final WebSocketOnDispose? onDispose;
const LKWebSocketOptions({ const WebSocketOptions({
this.onData, this.onData,
this.onError, this.onError,
this.onDispose, this.onDispose,
}); });
} }
abstract class LKWebSocket { abstract class LiveKitWebSocket {
void send(List<int> data); void send(List<int> data);
void dispose(); void dispose();
static Future<LKWebSocket> connect( static Future<LiveKitWebSocket> connect(
Uri uri, [ Uri uri, [
LKWebSocketOptions? options, WebSocketOptions? options,
]) => ]) =>
lkWebSocketConnect(uri, options); lkWebSocketConnect(uri, options);
} }
+14 -15
View File
@@ -1,22 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' as io; import 'dart:io' as io;
import 'package:livekit_client/src/logger.dart'; import '../../logger.dart';
import '../interface.dart'; import '../interface.dart';
Future<LKWebSocketIO> lkWebSocketConnect( Future<LiveKitWebSocketIO> lkWebSocketConnect(
Uri uri, [ Uri uri, [
LKWebSocketOptions? options, WebSocketOptions? options,
]) => ]) =>
LKWebSocketIO.connect(uri, options); LiveKitWebSocketIO.connect(uri, options);
class LKWebSocketIO implements LKWebSocket { class LiveKitWebSocketIO implements LiveKitWebSocket {
final io.WebSocket _ws; final io.WebSocket _ws;
final LKWebSocketOptions? options; final WebSocketOptions? options;
late final StreamSubscription _subscription; late final StreamSubscription _subscription;
LKWebSocketIO._( LiveKitWebSocketIO._(
this._ws, [ this._ws, [
this.options, this.options,
]) { ]) {
@@ -36,18 +35,18 @@ class LKWebSocketIO implements LKWebSocket {
@override @override
void send(List<int> data) => _ws.add(data); void send(List<int> data) => _ws.add(data);
static Future<LKWebSocketIO> connect( static Future<LiveKitWebSocketIO> connect(
Uri uri, [ Uri uri, [
LKWebSocketOptions? options, WebSocketOptions? options,
]) async { ]) async {
logger.fine('LKWebSocketIO connect (uri: ${uri.toString()})'); logger.fine('WebSocketIO connect (uri: ${uri.toString()})');
try { try {
final ws = await io.WebSocket.connect(uri.toString()); final ws = await io.WebSocket.connect(uri.toString());
logger.fine('LKWebSocketIO connected'); logger.fine('WebSocketIO connected');
return LKWebSocketIO._(ws, options); return LiveKitWebSocketIO._(ws, options);
} catch (_) { } catch (_) {
logger.severe('LKWebSocketIO error ${_}'); logger.severe('WebSocketIO error ${_}');
throw LKWebSocketError.connect(); throw WebSocketException.connect();
} }
} }
} }
+11 -11
View File
@@ -6,19 +6,19 @@ import 'dart:typed_data';
import '../interface.dart'; import '../interface.dart';
Future<LKWebSocketWeb> lkWebSocketConnect( Future<LiveKitWebSocketWeb> lkWebSocketConnect(
Uri uri, [ Uri uri, [
LKWebSocketOptions? options, WebSocketOptions? options,
]) => ]) =>
LKWebSocketWeb.connect(uri, options); LiveKitWebSocketWeb.connect(uri, options);
class LKWebSocketWeb implements LKWebSocket { class LiveKitWebSocketWeb implements LiveKitWebSocket {
final html.WebSocket _ws; final html.WebSocket _ws;
final LKWebSocketOptions? options; final WebSocketOptions? options;
late final StreamSubscription _messageSubscription; late final StreamSubscription _messageSubscription;
late final StreamSubscription _closeSubscription; late final StreamSubscription _closeSubscription;
LKWebSocketWeb._( LiveKitWebSocketWeb._(
this._ws, [ this._ws, [
this.options, this.options,
]) { ]) {
@@ -41,14 +41,14 @@ class LKWebSocketWeb implements LKWebSocket {
_ws.close(); _ws.close();
} }
static Future<LKWebSocketWeb> connect( static Future<LiveKitWebSocketWeb> connect(
Uri uri, [ Uri uri, [
LKWebSocketOptions? options, WebSocketOptions? options,
]) async { ]) async {
final completer = Completer<LKWebSocketWeb>(); final completer = Completer<LiveKitWebSocketWeb>();
final ws = html.WebSocket(uri.toString()); final ws = html.WebSocket(uri.toString());
ws.onOpen.listen((_) => completer.complete(LKWebSocketWeb._(ws, options))); ws.onOpen.listen((_) => completer.complete(LiveKitWebSocketWeb._(ws, options)));
ws.onError.listen((_) => completer.completeError(LKWebSocketError.connect())); ws.onError.listen((_) => completer.completeError(WebSocketException.connect()));
return completer.future; return completer.future;
} }
} }
+5 -19
View File
@@ -37,7 +37,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
collection: collection:
dependency: "direct main" dependency: transitive
description: description:
name: collection name: collection
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -72,7 +72,7 @@ packages:
source: hosted source: hosted
version: "6.1.2" version: "6.1.2"
fixnum: fixnum:
dependency: "direct main" dependency: transitive
description: description:
name: fixnum name: fixnum
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -129,7 +129,7 @@ packages:
name: logging name: logging
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -157,14 +157,14 @@ packages:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.3" version: "2.0.4"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
name: path_provider_linux name: path_provider_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "2.1.0"
path_provider_macos: path_provider_macos:
dependency: transitive dependency: transitive
description: description:
@@ -221,13 +221,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
quiver:
dependency: transitive
description:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -282,13 +275,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.2" version: "0.4.2"
tuple:
dependency: "direct main"
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+2 -50
View File
@@ -10,72 +10,24 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
collection: ^1.15.0
fixnum: ^1.0.0
flutter_webrtc: ^0.6.7 flutter_webrtc: ^0.6.7
http: ^0.13.3 http: ^0.13.3
logging: ^1.0.1 logging: ^1.0.2
protobuf: ^2.0.0
tuple: ^2.0.0
uuid: ^3.0.4 uuid: ^3.0.4
synchronized: ^3.0.0 synchronized: ^3.0.0
protobuf: ^2.0.0
#
# protobuf: # protobuf:
# git: # git:
# url: https://github.com/google/protobuf.dart.git # url: https://github.com/google/protobuf.dart.git
# ref: master # ref: master
# path: protobuf/ # path: protobuf/
#
# WebSocketChannel has design flaws # WebSocketChannel has design flaws
# https://github.com/dart-lang/web_socket_channel/issues/25 # https://github.com/dart-lang/web_socket_channel/issues/25
#
# web_socket_channel: ^2.1.0 # web_socket_channel: ^2.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^1.0.4 flutter_lints: ^1.0.4
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter.
flutter:
# To add assets to your package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/assets-and-images/#from-packages
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.
# To add custom fonts to your package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/custom-fonts/#from-packages