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:
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: $_');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
+4
-52
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user