Switch to new event system (#9)

* `SignalEvents` and `synchronized` mode for `EventsListenable`

* cascade syntax

* clean up

* `RoomEvents`

* `ParticipantEvents`

* all events implemented

* make it compile with M1 macs

* dispose logic

* cleaner connect logic

* ask to publish

* fix initial EngineTrackAddedEvent glitch

* fix unpublishTrack bug

* clean up

* better events docs

* organize event types

* clean up

* cleaner wait logic

* wait for event instead of using Completer

* notifyListeners
This commit is contained in:
Hiroshi Horie
2021-09-24 23:37:26 +09:00
committed by GitHub
parent f4bd82a919
commit 6445b8d2a8
36 changed files with 1270 additions and 948 deletions
+1 -1
View File
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>8.0</string> <string>9.0</string>
</dict> </dict>
</plist> </plist>
+4
View File
@@ -44,6 +44,10 @@ post_install do |installer|
# #
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.1' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.1'
#
# Make it compile with M1 macs
#
config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = 'arm64'
end end
end end
+10 -10
View File
@@ -2,14 +2,14 @@ PODS:
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_webrtc (0.2.2): - flutter_webrtc (0.2.2):
- Flutter - Flutter
- GoogleWebRTC (= 1.1.31999)
- Libyuv (= 1703) - Libyuv (= 1703)
- GoogleWebRTC (1.1.31999) - WebRTC-SDK (= 92.4515.05)
- Libyuv (1703) - Libyuv (1703)
- path_provider (0.0.1): - path_provider (0.0.1):
- Flutter - Flutter
- shared_preferences (0.0.1): - shared_preferences (0.0.1):
- Flutter - Flutter
- WebRTC-SDK (92.4515.05)
DEPENDENCIES: DEPENDENCIES:
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
@@ -19,8 +19,8 @@ DEPENDENCIES:
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- GoogleWebRTC
- Libyuv - Libyuv
- WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
Flutter: Flutter:
@@ -33,13 +33,13 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences/ios" :path: ".symlinks/plugins/shared_preferences/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
flutter_webrtc: 39898454258b54ba51996850d5da8d5d53bf1524 flutter_webrtc: c0cb88c7cbd057e6e667ab1560e10c74e2ceb65b
GoogleWebRTC: b39a78c4f5cc6b0323415b9233db03a2faa7b0f0
Libyuv: 5f79ced0ee66e60a612ca97de1e6ccacd187a437 Libyuv: 5f79ced0ee66e60a612ca97de1e6ccacd187a437
path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c path_provider: d1e9807085df1f9cc9318206cd649dc0b76be3de
shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d shared_preferences: 5033afbb22d372e15aff8ff766df9021b845f273
WebRTC-SDK: 7c76a541dbbffb0fc212aeb9902ec45a43c23996
PODFILE CHECKSUM: 6055d9653e1011c0b3b671abb92cdea979357e63 PODFILE CHECKSUM: 82aed1035f46bfa5b522f0d0dbf4730f17ec65ff
COCOAPODS: 1.10.1 COCOAPODS: 1.11.2
+3 -2
View File
@@ -331,6 +331,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
EXCLUDED_ARCHS = "";
GCC_C_LANGUAGE_STANDARD = gnu99; GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -342,7 +343,6 @@
IPHONEOS_DEPLOYMENT_TARGET = 12.1; IPHONEOS_DEPLOYMENT_TARGET = 12.1;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
@@ -406,6 +406,7 @@
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
EXCLUDED_ARCHS = "";
GCC_C_LANGUAGE_STANDARD = gnu99; GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -461,6 +462,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
EXCLUDED_ARCHS = "";
GCC_C_LANGUAGE_STANDARD = gnu99; GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -472,7 +474,6 @@
IPHONEOS_DEPLOYMENT_TARGET = 12.1; IPHONEOS_DEPLOYMENT_TARGET = 12.1;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
+19 -1
View File
@@ -2,6 +2,24 @@ import 'package:flutter/material.dart';
extension LKExampleExt on BuildContext { extension LKExampleExt on BuildContext {
// //
Future<bool?> showPublishDialog() => showDialog<bool>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Publish'),
content: const Text('Would you like to publish your Camera & Mic ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('NO'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('YES'),
),
],
),
);
Future<void> showErrorDialog(dynamic exception) => showDialog<void>( Future<void> showErrorDialog(dynamic exception) => showDialog<void>(
context: this, context: this,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@@ -74,7 +92,7 @@ extension LKExampleExt on BuildContext {
context: this, context: this,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Received data'), title: const Text('Received data'),
content: Text('"${data}"'), content: Text(data),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
+2
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:livekit_example/theme.dart'; import 'package:livekit_example/theme.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'pages/connect.dart'; import 'pages/connect.dart';
void main() { void main() {
print('This is a test for ${SignalTrickleEvent} test.');
// configure logs for debugging // configure logs for debugging
Logger.root.level = Level.FINE; Logger.root.level = Level.FINE;
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
+7 -3
View File
@@ -62,6 +62,9 @@ class _ConnectPageState extends State<ConnectPage> {
_busy = true; _busy = true;
}); });
// Save for next time
await _writePrefs();
print('Connecting with url: ${_uriCtrl.text}, token: ${_tokenCtrl.text}...'); print('Connecting with url: ${_uriCtrl.text}, token: ${_tokenCtrl.text}...');
final room = await LiveKitClient.connect( final room = await LiveKitClient.connect(
@@ -74,9 +77,6 @@ class _ConnectPageState extends State<ConnectPage> {
), ),
); );
// Save for next time
await _writePrefs();
await Navigator.push<void>( await Navigator.push<void>(
ctx, ctx,
MaterialPageRoute(builder: (_) => RoomPage(room)), MaterialPageRoute(builder: (_) => RoomPage(room)),
@@ -122,10 +122,14 @@ class _ConnectPageState extends State<ConnectPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( TextField(
enableSuggestions: false,
autocorrect: false,
controller: _uriCtrl, controller: _uriCtrl,
decoration: const InputDecoration(labelText: 'URL'), decoration: const InputDecoration(labelText: 'URL'),
), ),
TextField( TextField(
enableSuggestions: false,
autocorrect: false,
controller: _tokenCtrl, controller: _tokenCtrl,
decoration: const InputDecoration(labelText: 'Token'), decoration: const InputDecoration(labelText: 'Token'),
), ),
+64 -57
View File
@@ -2,12 +2,12 @@ import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart';
import '../exts.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 {
// //
@@ -22,29 +22,51 @@ class RoomPage extends StatefulWidget {
State<StatefulWidget> createState() => _RoomPageState(); State<StatefulWidget> createState() => _RoomPageState();
} }
class _RoomPageState extends State<RoomPage> with RoomDelegate { class _RoomPageState extends State<RoomPage> {
// //
List<Participant> participants = []; List<Participant> participants = [];
late final _listener = EventsListener<LiveKitEvent>(widget.room.events);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
widget.room.delegate = this; widget.room.addListener(_onRoomDidUpdate);
widget.room.addListener(_onChange); _setUpListeners();
_onConnected(); _sortParticipants();
WidgetsBinding.instance?.addPostFrameCallback((_) => _askPublish());
} }
@override @override
void dispose() { void dispose() {
widget.room.delegate = null; // always dispose listener
widget.room.removeListener(_onChange); (() async {
widget.room.removeListener(_onRoomDidUpdate);
await _listener.dispose();
await widget.room.dispose();
})();
super.dispose(); super.dispose();
} }
void _onConnected() async { void _setUpListeners() => _listener
..on<RoomDisconnectedEvent>((_) => Navigator.pop(context))
..on<DataReceivedEvent>((event) {
String decoded = 'Failed to decode';
try {
decoded = utf8.decode(event.data);
} catch (_) {
print('Failed to decode: $_');
}
context.showDataReceivedDialog(decoded);
});
void _askPublish() async {
final result = await context.showPublishDialog();
if (result != true) return;
// video will fail when running in ios simulator // video will fail when running in ios simulator
try { try {
final localVideo = await LocalVideoTrack.createCameraTrack(); // Defaults to camera // Create video track
final localVideo = await LocalVideoTrack.createCameraTrack();
// Try to publish the video
await widget.room.localParticipant.publishVideoTrack( await widget.room.localParticipant.publishVideoTrack(
localVideo, localVideo,
// options: TrackPublishOptions( // options: TrackPublishOptions(
@@ -52,20 +74,22 @@ class _RoomPageState extends State<RoomPage> with RoomDelegate {
// videoEncoding: VideoParameters.presetQVGA169.encoding, // videoEncoding: VideoParameters.presetQVGA169.encoding,
// ), // ),
); );
} catch (e) {
print('could not publish video: $e'); // Create mic track
final localAudio = await LocalAudioTrack.create();
// // Try to publish audio
await widget.room.localParticipant.publishAudioTrack(localAudio);
} catch (error) {
print('could not publish video: $error');
await context.showErrorDialog(error);
} }
final localAudio = await LocalAudioTrack.create();
await widget.room.localParticipant.publishAudioTrack(localAudio);
sortParticipants();
} }
void _onChange() { void _onRoomDidUpdate() {
sortParticipants(); _sortParticipants();
} }
void sortParticipants() { void _sortParticipants() {
List<Participant> participants = []; List<Participant> participants = [];
participants.addAll(widget.room.participants.values); participants.addAll(widget.room.participants.values);
// sort speakers for the grid // sort speakers for the grid
@@ -106,48 +130,31 @@ class _RoomPageState extends State<RoomPage> with RoomDelegate {
}); });
} }
@override
void onDataReceived(RemoteParticipant participant, List<int> data) async {
await context.showDataReceivedDialog(utf8.decode(data));
}
@override
void onDisconnected() {
print('disconnected: $context');
Navigator.pop(context);
}
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
// with a provider, any child/descendent widget can be updated if they body: Column(
// are a Consumer of Room. children: [
body: ChangeNotifierProvider.value( Expanded(
value: widget.room, child:
child: Column( participants.isNotEmpty ? ParticipantWidget(participants.first) : Container()),
children: [ SizedBox(
Expanded( height: 100,
child: participants.isNotEmpty child: ListView.builder(
? ParticipantWidget(participants.first) scrollDirection: Axis.horizontal,
: Container()), itemCount: math.max(0, participants.length - 1),
SizedBox( itemBuilder: (BuildContext context, int index) => Container(
height: 100, width: 100,
child: ListView.builder( height: 100,
scrollDirection: Axis.horizontal, padding: const EdgeInsets.all(2),
itemCount: math.max(0, participants.length - 1), child: ParticipantWidget(participants[index + 1], quality: VideoQuality.LOW),
itemBuilder: (BuildContext context, int index) => Container(
width: 100,
height: 100,
padding: const EdgeInsets.all(2),
child: ParticipantWidget(participants[index + 1], quality: VideoQuality.LOW),
),
), ),
), ),
SafeArea( ),
top: false, SafeArea(
child: ControlsWidget(widget.room), top: false,
), child: ControlsWidget(widget.room),
], ),
), ],
), ),
); );
} }
+2 -2
View File
@@ -102,8 +102,8 @@ class _ControlsWidgetState extends State<ControlsWidget> {
// //
final lp = widget.room.localParticipant; final lp = widget.room.localParticipant;
for (final tracks in lp.videoTracks) { for (final track in lp.videoTracks) {
await lp.unpublishTrack(tracks.track!); await lp.unpublishTrack(track.sid);
} }
try { try {
+1 -1
View File
@@ -21,7 +21,7 @@ class ParticipantWidget extends StatefulWidget {
State<StatefulWidget> createState() => _ParticipantWidgetState(); State<StatefulWidget> createState() => _ParticipantWidgetState();
} }
class _ParticipantWidgetState extends State<ParticipantWidget> with ParticipantDelegate { class _ParticipantWidgetState extends State<ParticipantWidget> {
// //
TrackPublication? videoPub; TrackPublication? videoPub;
TrackPublication? audioPub; TrackPublication? audioPub;
+12 -10
View File
@@ -56,7 +56,7 @@ packages:
name: eva_icons_flutter name: eva_icons_flutter
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "3.0.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -110,9 +110,11 @@ packages:
flutter_webrtc: flutter_webrtc:
dependency: transitive dependency: transitive
description: description:
name: flutter_webrtc path: "."
url: "https://pub.dartlang.org" ref: use-custom-webrtc-build
source: hosted resolved-ref: "4942e7faec2e5775d35c42e22c2929ca6ca53769"
url: "https://github.com/livekit/flutter-webrtc"
source: git
version: "0.6.7" version: "0.6.7"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
@@ -197,14 +199,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.5"
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:
@@ -274,7 +276,7 @@ packages:
name: shared_preferences name: shared_preferences
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.7" version: "2.0.8"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@@ -391,7 +393,7 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.8" version: "2.2.9"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -400,5 +402,5 @@ packages:
source: hosted source: hosted
version: "0.2.0" version: "0.2.0"
sdks: sdks:
dart: ">=2.13.0 <3.0.0" dart: ">=2.14.0 <3.0.0"
flutter: ">=2.0.0" flutter: ">=2.5.0"
+2
View File
@@ -2,7 +2,9 @@
library livekit_client; library livekit_client;
export 'src/errors.dart'; export 'src/errors.dart';
export 'src/events.dart';
export 'src/livekit.dart'; export 'src/livekit.dart';
export 'src/managers/event.dart';
export 'src/options.dart'; export 'src/options.dart';
export 'src/participant/local_participant.dart'; export 'src/participant/local_participant.dart';
export 'src/participant/local_participant.dart'; export 'src/participant/local_participant.dart';
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/src/logger.dart';
// dispose safe change notifier
abstract class LKChangeNotifier extends ChangeNotifier {
bool _disposed = false;
bool get isDisposed => _disposed;
@override
void dispose() {
_disposed = true;
super.dispose();
}
@override
void addListener(VoidCallback listener) {
if (_disposed) {
logger.warning('calling addListener on a disposed ChangeNotifier');
return;
}
super.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
if (_disposed) {
logger.warning('calling removeListener on a disposed ChangeNotifier');
return;
}
super.removeListener(listener);
}
}
+7
View File
@@ -0,0 +1,7 @@
class Timeouts {
static const connection = Duration(seconds: 5);
static const debounce = Duration(milliseconds: 100);
static const publish = Duration(seconds: 3);
static const iceConnection = Duration(seconds: 5);
static const iceRestart = Duration(seconds: 10);
}
+301 -79
View File
@@ -1,120 +1,257 @@
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
import 'package:meta/meta.dart';
import 'participant/participant.dart';
import 'participant/remote_participant.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 'track/remote_track_publication.dart';
import 'track/track.dart';
import 'types.dart';
abstract class LiveKitEvent {} abstract class LiveKitEvent {}
abstract class RoomEvent implements LiveKitEvent { abstract class RoomEvent implements LiveKitEvent {}
const RoomEvent();
abstract class ParticipantEvent implements LiveKitEvent {}
abstract class TrackEvent implements LiveKitEvent {}
abstract class EngineEvent implements LiveKitEvent {}
abstract class SignalEvent implements LiveKitEvent {}
/// When the connection to the server has been interrupted and it's attempting
/// to reconnect.
/// Emitted by [Room].
class RoomReconnectingEvent with RoomEvent {
const RoomReconnectingEvent();
} }
abstract class ParticipantEvent implements LiveKitEvent { /// Connection to room is re-established. All existing state is preserved.
const ParticipantEvent(); /// Emitted by [Room].
class RoomReconnectedEvent with RoomEvent {
const RoomReconnectedEvent();
} }
abstract class EngineEvent implements LiveKitEvent { /// Disconnected from the room
const EngineEvent(); /// Emitted by [Room].
class RoomDisconnectedEvent with RoomEvent {
const RoomDisconnectedEvent();
} }
abstract class TrackEvent implements LiveKitEvent { /// When a new [RemoteParticipant] joins *after* the current participant has connected
const TrackEvent(); /// It will not fire for participants that are already in the room
/// Emitted by [Room].
class ParticipantConnectedEvent with RoomEvent {
final RemoteParticipant participant;
const ParticipantConnectedEvent({
required this.participant,
});
}
/// When a [RemoteParticipant] leaves the room
/// Emitted by [Room].
class ParticipantDisconnectedEvent with RoomEvent {
final RemoteParticipant participant;
const ParticipantDisconnectedEvent({
required this.participant,
});
}
/// Active speakers changed. List of speakers are ordered by their audio level.
/// loudest speakers first. This will include the [LocalParticipant] too.
class ActiveSpeakersChangedEvent with RoomEvent {
final List<Participant> speakers;
const ActiveSpeakersChangedEvent({
required this.speakers,
});
}
class AudioPlaybackChangedEvent with RoomEvent {
const AudioPlaybackChangedEvent();
}
/// When a new [Track] is published to [Room] *after* the current participant has
/// joined. It will not fire for tracks that are already published.
/// Emitted by [Room] and [RemoteParticipant].
class TrackPublishedEvent with RoomEvent, ParticipantEvent {
final RemoteParticipant participant;
final RemoteTrackPublication publication;
const TrackPublishedEvent({
required this.participant,
required this.publication,
});
}
/// The participant has unpublished one of their [Track].
/// Emitted by [Room] and [RemoteParticipant].
class TrackUnpublishedEvent with RoomEvent, ParticipantEvent {
final RemoteParticipant participant;
final RemoteTrackPublication publication;
const TrackUnpublishedEvent({
required this.participant,
required this.publication,
});
}
/// [LocalParticipant] has subscribed to a new track published by a
/// [RemoteParticipant].
/// Emitted by [Room] and [RemoteParticipant].
class TrackSubscribedEvent with RoomEvent, ParticipantEvent {
final RemoteParticipant participant;
final Track track;
final RemoteTrackPublication publication;
const TrackSubscribedEvent({
required this.participant,
required this.track,
required this.publication,
});
}
@internal
class ParticipantInfoUpdatedEvent with ParticipantEvent {
final RemoteParticipant participant;
const ParticipantInfoUpdatedEvent({
required this.participant,
});
}
/// An error has occured during track subscription.
/// Emitted by [Room] and [RemoteParticipant].
class TrackSubscriptionExceptionEvent with RoomEvent, ParticipantEvent {
final RemoteParticipant participant;
final String? sid;
final TrackSubscribeFailReason reason;
const TrackSubscriptionExceptionEvent({
required this.participant,
this.sid,
required this.reason,
});
}
/// The [LocalParticipant] has unsubscribed from a track published by a
/// [RemoteParticipant]. This event is fired when the track was unpublished.
/// Emitted by [Room] and [RemoteParticipant].
class TrackUnsubscribedEvent with RoomEvent, ParticipantEvent {
final RemoteParticipant participant;
final Track track;
final RemoteTrackPublication publication;
const TrackUnsubscribedEvent({
required this.participant,
required this.track,
required this.publication,
});
}
/// A Participant has muted one of the track.
/// Emitted on [RemoteParticipant] and [LocalParticipant].
class TrackMutedEvent with RoomEvent, ParticipantEvent {
final Participant participant;
final TrackPublication track;
const TrackMutedEvent({
required this.participant,
required this.track,
});
}
/// This participant has unmuted one of their tracks
/// Emitted on [RemoteParticipant] and [LocalParticipant].
class TrackUnmutedEvent with RoomEvent, ParticipantEvent {
final Participant participant;
final TrackPublication track;
const TrackUnmutedEvent({
required this.participant,
required this.track,
});
} }
// //
// Room events // common events for both Room/Participant.
// //
class RoomReconnectingEvent extends RoomEvent {}
class RoomReconnectedEvent extends RoomEvent {} /// Participant metadata is a simple way for app-specific state to be pushed to
/// all users. When RoomService.UpdateParticipantMetadata is called to change a
/// [Participant]'s state, *all* [Participant]s in the room will fire this event.
/// Emitted on [Participant].
class ParticipantMetadataUpdatedEvent with RoomEvent, ParticipantEvent {
final Participant participant;
const ParticipantMetadataUpdatedEvent({
required this.participant,
});
}
class RoomDisconnectedEvent extends RoomEvent {} /// Data received from [RemoteParticipant].
/// Data packets provides the ability to use LiveKit to send/receive arbitrary
/// payloads.
/// Emitted on [Room] and [RemoteParticipant].
class DataReceivedEvent with RoomEvent, ParticipantEvent {
/// Sender of the data. This may be null if data is sent from Server API.
final RemoteParticipant? participant;
final List<int> data;
const DataReceivedEvent({
required this.participant,
required this.data,
});
}
class RoomParticipantConnectedEvent extends RoomEvent {} /// The participant's isSpeaking property has changed
/// Emitted on [Participant].
class RoomParticipantDisconnectedEvent extends RoomEvent {} class SpeakingChangedEvent with RoomEvent, ParticipantEvent {
final Participant participant;
class RoomTrackPublishedEvent extends RoomEvent {} final bool speaking;
const SpeakingChangedEvent({
class RoomTrackSubscribedEvent extends RoomEvent {} required this.participant,
required this.speaking,
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 // Engine events
// //
class EngineConnectedEvent extends EngineEvent {} class EngineConnectedEvent with EngineEvent {
const EngineConnectedEvent();
}
class EngineDisconnectedEvent extends EngineEvent {} class EngineDisconnectedEvent with EngineEvent {
const EngineDisconnectedEvent();
}
class EngineReconnectingEvent extends EngineEvent {} class EngineReconnectingEvent with EngineEvent {
const EngineReconnectingEvent();
}
class EngineReconnectedEvent extends EngineEvent {} class EngineReconnectedEvent with EngineEvent {
const EngineReconnectedEvent();
}
class EngineParticipantUpdateEvent extends EngineEvent { class EngineParticipantUpdateEvent with EngineEvent {
final List<lk_models.ParticipantInfo> participants; final List<lk_models.ParticipantInfo> participants;
const EngineParticipantUpdateEvent({ const EngineParticipantUpdateEvent({
required this.participants, required this.participants,
}); });
} }
class EngineMediaTrackAddedEvent extends EngineEvent { class EngineTrackAddedEvent with EngineEvent {
final rtc.MediaStreamTrack track; final rtc.MediaStreamTrack track;
final rtc.MediaStream? stream; final rtc.MediaStream stream;
final rtc.RTCRtpReceiver? receiver; final rtc.RTCRtpReceiver? receiver;
const EngineMediaTrackAddedEvent({ const EngineTrackAddedEvent({
required this.track, required this.track,
required this.stream, required this.stream,
required this.receiver, required this.receiver,
}); });
} }
class EngineSpeakersUpdateEvent extends EngineEvent { class EngineSpeakersUpdateEvent with EngineEvent {
final List<lk_models.SpeakerInfo> speakers; final List<lk_models.SpeakerInfo> speakers;
const EngineSpeakersUpdateEvent({ const EngineSpeakersUpdateEvent({
required this.speakers, required this.speakers,
}); });
} }
class EngineDataPacketReceivedEvent extends EngineEvent { class EngineDataPacketReceivedEvent with EngineEvent {
final lk_models.UserPacket packet; final lk_models.UserPacket packet;
final lk_models.DataPacket_Kind kind; final lk_models.DataPacket_Kind kind;
const EngineDataPacketReceivedEvent({ const EngineDataPacketReceivedEvent({
@@ -123,7 +260,7 @@ class EngineDataPacketReceivedEvent extends EngineEvent {
}); });
} }
class EngineRemoteMuteChangedEvent extends EngineEvent { class EngineRemoteMuteChangedEvent with EngineEvent {
final String sid; final String sid;
final bool muted; final bool muted;
const EngineRemoteMuteChangedEvent({ const EngineRemoteMuteChangedEvent({
@@ -133,7 +270,7 @@ class EngineRemoteMuteChangedEvent extends EngineEvent {
} }
// added // added
abstract class EngineIceStateUpdatedEvent implements EngineEvent { abstract class EngineIceStateUpdatedEvent with EngineEvent {
final rtc.RTCIceConnectionState iceState; final rtc.RTCIceConnectionState iceState;
final bool isPrimary; final bool isPrimary;
const EngineIceStateUpdatedEvent({ const EngineIceStateUpdatedEvent({
@@ -166,16 +303,101 @@ class EnginePublisherIceStateUpdatedEvent extends EngineIceStateUpdatedEvent {
// Track events // Track events
// //
class TrackMessageEvent extends TrackEvent {} class TrackMessageEvent with TrackEvent {
const TrackMessageEvent();
}
class TrackMutedEvent extends TrackEvent {} class TrackUpdateSettingsEvent with TrackEvent {
const TrackUpdateSettingsEvent();
}
class TrackUnmutedEvent extends TrackEvent {} class TrackUpdateSubscriptionEvent with TrackEvent {
const TrackUpdateSubscriptionEvent();
}
class TrackUpdateSettingsEvent extends TrackEvent {} class TrackAudioPlaybackStartedEvent with TrackEvent {
const TrackAudioPlaybackStartedEvent();
}
class TrackUpdateSubscriptionEvent extends TrackEvent {} class TrackAudioPlaybackFailedEvent with TrackEvent {
const TrackAudioPlaybackFailedEvent();
}
class TrackAudioPlaybackStartedEvent extends TrackEvent {} //
// Signal events
//
class SignalConnectedEvent with SignalEvent {
final lk_rtc.JoinResponse response;
const SignalConnectedEvent({
required this.response,
});
}
class TrackAudioPlaybackFailedEvent extends TrackEvent {} class SignalCloseEvent with SignalEvent {
final CloseReason? reason;
const SignalCloseEvent({
this.reason,
});
}
class SignalOfferEvent with SignalEvent {
final rtc.RTCSessionDescription sd;
const SignalOfferEvent({
required this.sd,
});
}
class SignalAnswerEvent with SignalEvent {
final rtc.RTCSessionDescription sd;
const SignalAnswerEvent({
required this.sd,
});
}
class SignalTrickleEvent with SignalEvent {
final rtc.RTCIceCandidate candidate;
final lk_rtc.SignalTarget target;
const SignalTrickleEvent({
required this.candidate,
required this.target,
});
}
class SignalParticipantUpdateEvent with SignalEvent {
final List<lk_models.ParticipantInfo> updates;
const SignalParticipantUpdateEvent({
required this.updates,
});
}
class SignalLocalTrackPublishedEvent with SignalEvent {
final String cid;
final lk_models.TrackInfo track;
const SignalLocalTrackPublishedEvent({
required this.cid,
required this.track,
});
}
class SignalActiveSpeakersChangedEvent with SignalEvent {
final List<lk_models.SpeakerInfo> speakers;
const SignalActiveSpeakersChangedEvent({
required this.speakers,
});
}
class SignalLeaveEvent with SignalEvent {
final bool canReconnect;
const SignalLeaveEvent({
required this.canReconnect,
});
}
class SignalMuteTrackEvent with SignalEvent {
final String sid;
final bool muted;
const SignalMuteTrackEvent({
required this.sid,
required this.muted,
});
}
+50 -38
View File
@@ -2,43 +2,51 @@ import 'dart:convert';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'proto/livekit_rtc.pb.dart' as lk_rtc; 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 'proto/livekit_rtc.pb.dart' as lk_rtc;
import 'types.dart'; import 'types.dart';
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 LiveKitEventExt on Iterable<EventsEmitter<LiveKitEvent>> {
void emit(LiveKitEvent event) => forEach((emitter) => emitter.emit(event));
}
extension ICEServerExt on lk_rtc.ICEServer {
RTCIceServer toSDKType() => RTCIceServer(
urls: urls,
username: username.isNotEmpty ? username : null,
credential: credential.isNotEmpty ? username : null,
);
}
extension IterableExt<E> on Iterable<E> { extension IterableExt<E> on Iterable<E> {
E? elementAtOrNull(int index) => (index >= 0 && index < length) ? elementAt(index) : null; 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 { extension ObjectExt on Object {
String get objectId => '${runtimeType}#${hashCode}'; String get objectId => '${runtimeType}#${hashCode}';
} }
extension RTCIceTransportPolicyExt on RTCIceTransportPolicy { extension ProtocolVersionExt on ProtocolVersion {
String toStringValue() => { String toStringValue() => {
RTCIceTransportPolicy.all: 'all', ProtocolVersion.protocol2: '2',
RTCIceTransportPolicy.relay: 'relay', ProtocolVersion.protocol3: '3',
}[this]!; }[this]!;
} }
extension SessionDescriptionExt on lk_rtc.SessionDescription { extension ReliabilityExt on Reliability {
rtc.RTCSessionDescription toSDKType() { lk_models.DataPacket_Kind toPBType() => {
return rtc.RTCSessionDescription(sdp, type); Reliability.reliable: lk_models.DataPacket_Kind.RELIABLE,
} Reliability.lossy: lk_models.DataPacket_Kind.LOSSY,
} }[this]!;
extension RTCSessionDescriptionExt on rtc.RTCSessionDescription {
lk_rtc.SessionDescription toSDKType() {
return lk_rtc.SessionDescription(type: type, sdp: sdp);
}
} }
extension RTCIceCandidateExt on rtc.RTCIceCandidate { extension RTCIceCandidateExt on rtc.RTCIceCandidate {
@@ -54,26 +62,30 @@ extension RTCIceCandidateExt on rtc.RTCIceCandidate {
String toJson() => json.encode(toMap()); String toJson() => json.encode(toMap());
} }
extension ICEServerExt on lk_rtc.ICEServer { extension RTCIceConnectionStateExt on rtc.RTCIceConnectionState {
RTCIceServer toSDKType() => RTCIceServer( bool isConnected() => [
urls: urls, rtc.RTCIceConnectionState.RTCIceConnectionStateConnected,
username: username.isNotEmpty ? username : null, rtc.RTCIceConnectionState.RTCIceConnectionStateCompleted,
credential: credential.isNotEmpty ? username : null, ].contains(this);
); }
extension RTCIceTransportPolicyExt on RTCIceTransportPolicy {
String toStringValue() => {
RTCIceTransportPolicy.all: 'all',
RTCIceTransportPolicy.relay: 'relay',
}[this]!;
} }
// not so neat to directly expose protobuf types so we // not so neat to directly expose protobuf types so we
// define our own types (and convert methods) // define our own types (and convert methods)
extension DataPacketKindExt on lk_models.DataPacket_Kind { extension RTCSessionDescriptionExt on rtc.RTCSessionDescription {
Reliability toSDKType() => { lk_rtc.SessionDescription toSDKType() {
lk_models.DataPacket_Kind.RELIABLE: Reliability.reliable, return lk_rtc.SessionDescription(type: type, sdp: sdp);
lk_models.DataPacket_Kind.LOSSY: Reliability.lossy, }
}[this]!;
} }
extension ReliabilityExt on Reliability { extension SessionDescriptionExt on lk_rtc.SessionDescription {
lk_models.DataPacket_Kind toPBType() => { rtc.RTCSessionDescription toSDKType() {
Reliability.reliable: lk_models.DataPacket_Kind.RELIABLE, return rtc.RTCSessionDescription(sdp, type);
Reliability.lossy: lk_models.DataPacket_Kind.LOSSY, }
}[this]!;
} }
+6 -8
View File
@@ -11,12 +11,10 @@ class LiveKitClient {
String url, String url,
String token, { String token, {
ConnectOptions? options, ConnectOptions? options,
}) { }) =>
final room = Room(); Room.connect(
return room.connect( url,
url, token,
token, options: options,
options: options, );
);
}
} }
-3
View File
@@ -1,6 +1,3 @@
//
//
//
import 'package:async/async.dart'; import 'package:async/async.dart';
class CancelableDelayManager { class CancelableDelayManager {
+45 -18
View File
@@ -1,19 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:synchronized/synchronized.dart' as sync;
import '../errors.dart'; import '../errors.dart';
import '../events.dart';
import '../extensions.dart'; import '../extensions.dart';
import '../logger.dart'; import '../logger.dart';
import '../types.dart'; import '../types.dart';
// Type-safe, multi-listenable, dispose safe event handling // Type-safe, multi-listenable, dispose safe event handling
// TODO: Move to a separate package
class EventsEmitter<T extends LiveKitEvent> extends EventsListenable<T> { class EventsEmitter<T> extends EventsListenable<T> {
// suppport for multiple event listeners // suppport for multiple event listeners
final streamCtrl = StreamController<T>.broadcast(sync: false); final streamCtrl = StreamController<T>.broadcast(sync: false);
EventsEmitter({
bool listenSynchronized = false,
}) : super(synchronized: listenSynchronized);
@override @override
EventsEmitter<T> get emitter => this; EventsEmitter<T> get emitter => this;
@@ -25,6 +30,7 @@ class EventsEmitter<T extends LiveKitEvent> extends EventsListenable<T> {
} }
@override @override
@mustCallSuper
Future<void> dispose() async { Future<void> dispose() async {
await streamCtrl.close(); await streamCtrl.close();
await super.dispose(); await super.dispose();
@@ -32,21 +38,31 @@ class EventsEmitter<T extends LiveKitEvent> extends EventsListenable<T> {
} }
// for listening only // for listening only
class EventsListener<T extends LiveKitEvent> extends EventsListenable<T> { class EventsListener<T> extends EventsListenable<T> {
@override @override
final EventsEmitter<T> emitter; final EventsEmitter<T> emitter;
EventsListener({ EventsListener(
required this.emitter, this.emitter, {
}); bool synchronized = false,
}) : super(
synchronized: synchronized,
);
} }
// ensures all listeners will close on dispose // ensures all listeners will close on dispose
abstract class EventsListenable<T extends LiveKitEvent> { abstract class EventsListenable<T> {
// the emitter to listen to // the emitter to listen to
EventsEmitter<T> get emitter; EventsEmitter<T> get emitter;
bool synchronized;
// keep track of listeners to cancel later // keep track of listeners to cancel later
final _listeners = <StreamSubscription<T>>[]; final _listeners = <StreamSubscription<T>>[];
final _syncLock = sync.Lock();
EventsListenable({
required this.synchronized,
});
@mustCallSuper @mustCallSuper
Future<void> dispose() async { Future<void> dispose() async {
@@ -58,8 +74,19 @@ abstract class EventsListenable<T extends LiveKitEvent> {
} }
// listens to all events, guaranteed to be cancelled on dispose // listens to all events, guaranteed to be cancelled on dispose
CancelListenFunc listen(Function(T) onEvent) { CancelListenFunc listen(FutureOr<void> Function(T) onEvent) {
final listener = emitter.streamCtrl.stream.listen(onEvent); //
FutureOr<void> Function(T) _func = onEvent;
if (synchronized) {
// ensure `onEvent` will trigger one by one (waits for previous `onEvent` to complete)
_func = (event) async {
await _syncLock.synchronized(() async {
await onEvent(event);
});
};
}
final listener = emitter.streamCtrl.stream.listen(_func);
_listeners.add(listener); _listeners.add(listener);
// make a cancel func to cancel listening and remove from list in 1 call // make a cancel func to cancel listening and remove from list in 1 call
@@ -74,34 +101,34 @@ abstract class EventsListenable<T extends LiveKitEvent> {
// convenience method to listen & filter a specific event type // convenience method to listen & filter a specific event type
CancelListenFunc on<E>( CancelListenFunc on<E>(
Function(E) then, { FutureOr<void> Function(E) then, {
bool Function(E)? filter, bool Function(E)? filter,
}) => }) =>
listen((event) { listen((event) async {
// event must be E // event must be E
if (event is! E) return; if (event is! E) return;
// filter must be true (if filter is used) // filter must be true (if filter is used)
if (filter != null && !filter(event as E)) return; if (filter != null && !filter(event)) return;
// cast to E // cast to E
then(event as E); await then(event);
}); });
// waits for a specific event type // waits for a specific event type
Future<void> waitFor<E>({ Future<E> waitFor<E>({
required Duration duration, required Duration duration,
bool Function(E)? filter, bool Function(E)? filter,
FutureOr<void> Function()? onTimeout, FutureOr<E> Function()? onTimeout,
}) async { }) async {
final completer = Completer<void>(); final completer = Completer<E>();
final _cancelFunc = on<E>( final _cancelFunc = on<E>(
(event) => completer.complete(), (event) => completer.complete(event),
filter: filter, filter: filter,
); );
try { try {
// wait to complete with timeout // wait to complete with timeout
await completer.future.timeout( return await completer.future.timeout(
duration, duration,
onTimeout: onTimeout ?? () => throw TimeoutException(), onTimeout: onTimeout ?? () => throw TimeoutException(),
); );
+24 -14
View File
@@ -2,15 +2,16 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; 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/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 '../rtc_engine.dart'; import '../rtc_engine.dart';
import '../track/local_audio_track.dart'; import '../track/local_audio_track.dart';
import '../track/local_track_publication.dart'; 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_publication.dart'; import '../track/track_publication.dart';
import '../types.dart'; import '../types.dart';
import '../utils.dart'; import '../utils.dart';
@@ -25,8 +26,13 @@ class LocalParticipant extends Participant {
required RTCEngine engine, required RTCEngine engine,
required lk_models.ParticipantInfo info, required lk_models.ParticipantInfo info,
this.defaultPublishOptions, this.defaultPublishOptions,
required EventsEmitter<RoomEvent> roomEvents,
}) : _engine = engine, }) : _engine = engine,
super(info.sid, info.identity) { super(
info.sid,
info.identity,
roomEvents: roomEvents,
) {
updateFromInfo(info); updateFromInfo(info);
} }
@@ -135,21 +141,25 @@ class LocalParticipant extends Participant {
} }
/// Unpublish a track that's already published /// Unpublish a track that's already published
Future<void> unpublishTrack(Track track) async { @override
final existing = tracks.values.where((element) => element.track == track); Future<void> unpublishTrack(String trackSid, {bool notify = false}) async {
if (existing.isEmpty) return; logger.finer('Unpublish track sid: $trackSid, notify: $notify');
final pub = trackPublications.remove(trackSid);
if (pub is! LocalTrackPublication) return;
final pub = existing.first; // final existing = tracks.values.where((element) => element.track == track);
// if (existing.isEmpty) return;
// final pub = existing.first;
final track = pub.track;
if (track != null) {
await track.stop();
await track.stop(); final sender = track.transceiver?.sender;
if (sender != null) {
final sender = track.transceiver?.sender; await engine.publisher?.pc.removeTrack(sender);
if (sender != null) { await engine.negotiate();
await engine.publisher?.pc.removeTrack(sender); }
await engine.negotiate();
} }
tracks.remove(pub.sid);
} }
/// Publish a new data payload to the room. /// Publish a new data payload to the room.
+67 -69
View File
@@ -1,50 +1,16 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meta/meta.dart';
import '../classes/change_notifier.dart';
import '../events.dart'; import '../events.dart';
import '../extensions.dart';
import '../logger.dart';
import '../managers/event.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/track.dart';
import '../track/track_publication.dart'; import '../track/track_publication.dart';
import 'remote_participant.dart'; import 'remote_participant.dart';
/// Callbacks for participant changes
mixin ParticipantDelegate {
/// The participant's metadata has changed
void onMetadataChanged(Participant participant) {}
/// The participant's isSpeaking property has changed
void onSpeakingChanged(Participant participant, bool speaking) {}
/// This participant has muted one of their tracks
void onTrackMuted(Participant participant, TrackPublication publication) {}
/// This participant has unmuted one of their tracks
void onTrackUnmuted(Participant participant, TrackPublication publication) {}
/// This participant has published a new [Track] to the [Room].
void onTrackPublished(RemoteParticipant participant, RemoteTrackPublication publication) {}
/// This participant has unpublished one of their [Track].
void onTrackUnpublished(RemoteParticipant participant, RemoteTrackPublication publication) {}
/// The [LocalParticipant] has subscribed to a new track published by this
/// [RemoteParticipant]
void onTrackSubscribed(
RemoteParticipant participant, Track track, RemoteTrackPublication publication) {}
/// The [LocalParticipant] has unsubscribed from a track published by this
/// [RemoteParticipant]. This event is fired when the track was unpublished
void onTrackUnsubscribed(
RemoteParticipant participant, Track track, RemoteTrackPublication publication) {}
/// Data received from this [RemoteParticipant].
void onDataReceived(RemoteParticipant participant, List<int> data) {}
/// An error has occured during track subscription.
void onTrackSubscriptionFailed(RemoteParticipant participant, String sid, String? message) {}
}
/// Represents a Participant in the room, notifies changes via delegates as /// Represents a Participant in the room, notifies changes via delegates as
/// well as ChangeNotifier/providers. /// well as ChangeNotifier/providers.
/// A change notification is triggered when /// A change notification is triggered when
@@ -52,15 +18,18 @@ mixin ParticipantDelegate {
/// - mute status changed /// - mute status changed
/// - added/removed subscribed tracks /// - added/removed subscribed tracks
/// - metadata changed /// - metadata changed
class Participant extends ChangeNotifier {
/// Base for [RemoteParticipant] and [LocalParticipant],
/// can not be instantiated directly.
abstract class Participant extends LKChangeNotifier {
/// map of track sid => published track /// map of track sid => published track
Map<String, TrackPublication> tracks = {}; final trackPublications = <String, TrackPublication>{};
/// audio level between 0-1, 1 being the loudest /// audio level between 0-1, 1 being the loudest
double audioLevel = 0; double audioLevel = 0;
/// server assigned unique id /// server assigned unique id
String sid; final String sid;
/// user-assigned identity /// user-assigned identity
String identity; String identity;
@@ -71,16 +40,12 @@ class Participant extends ChangeNotifier {
/// when the participant had last spoken /// when the participant had last spoken
DateTime? lastSpokeAt; DateTime? lastSpokeAt;
ParticipantDelegate? roomDelegate;
/// delegate to receive participant callbacks
ParticipantDelegate? delegate;
lk_models.ParticipantInfo? _participantInfo; lk_models.ParticipantInfo? _participantInfo;
bool _isSpeaking = false; bool _isSpeaking = false;
// suppport for multiple event listeners // suppport for multiple event listeners
final events = EventsEmitter<ParticipantEvent>(); final events = EventsEmitter<ParticipantEvent>();
final EventsEmitter<RoomEvent> roomEvents;
/// when the participant joined the room /// when the participant joined the room
DateTime get joinedAt { DateTime get joinedAt {
@@ -95,23 +60,40 @@ class Participant extends ChangeNotifier {
bool get isSpeaking => _isSpeaking; bool get isSpeaking => _isSpeaking;
/// true if participant is publishing an audio track and is muted /// true if participant is publishing an audio track and is muted
bool get isMuted { bool get isMuted => audioTracks.firstOrNull?.muted ?? true;
if (audioTracks.isEmpty) return false;
return audioTracks.first.muted;
}
bool get hasAudio => audioTracks.isNotEmpty; bool get hasAudio => audioTracks.isNotEmpty;
bool get hasVideo => videoTracks.isNotEmpty; bool get hasVideo => videoTracks.isNotEmpty;
/// tracks that are subscribed to /// tracks that are subscribed to
List<TrackPublication> get subscribedTracks => tracks.values.where((e) => e.subscribed).toList(); List<TrackPublication> get subscribedTracks =>
trackPublications.values.where((e) => e.subscribed).toList();
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
@internal
bool get hasInfo => _participantInfo != null; bool get hasInfo => _participantInfo != null;
Participant(this.sid, this.identity); Participant(
this.sid,
this.identity, {
required this.roomEvents,
}) {
// Any event emitted will trigger ChangeNotifier
events.listen((event) {
logger.fine('[ParticipantEvent] $event, will notifyListeners()');
notifyListeners();
});
}
@override
@mustCallSuper
Future<void> dispose() async {
logger.fine('$objectId dispose()');
await events.dispose();
super.dispose();
}
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
@@ -123,26 +105,29 @@ class Participant extends ChangeNotifier {
if (speaking) { if (speaking) {
lastSpokeAt = DateTime.now(); lastSpokeAt = DateTime.now();
} }
delegate?.onSpeakingChanged(this, speaking);
roomDelegate?.onSpeakingChanged(this, speaking); [events, roomEvents].emit(SpeakingChangedEvent(
notifyListeners(); participant: this,
speaking: speaking,
));
} }
void _setMetadata(String md) { void _setMetadata(String md) {
final changed = _participantInfo?.metadata != md; final changed = _participantInfo?.metadata != md;
metadata = md; metadata = md;
if (changed) { if (changed) {
delegate?.onMetadataChanged(this); [events, roomEvents].emit(ParticipantMetadataUpdatedEvent(
roomDelegate?.onMetadataChanged(this); participant: this,
notifyListeners(); ));
} }
} }
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
@internal
void updateFromInfo(lk_models.ParticipantInfo info) { void updateFromInfo(lk_models.ParticipantInfo info) {
identity = info.identity; identity = info.identity;
sid = info.sid; // participantSid = info.sid;
if (info.metadata.isNotEmpty) { if (info.metadata.isNotEmpty) {
_setMetadata(info.metadata); _setMetadata(info.metadata);
} }
@@ -151,23 +136,36 @@ class Participant extends ChangeNotifier {
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
void muteChanged() { @internal
notifyListeners();
}
/// for internal use
/// {@nodoc}
void addTrackPublication(TrackPublication pub) { void addTrackPublication(TrackPublication pub) {
pub.track?.sid = pub.sid; pub.track?.sid = pub.sid;
tracks[pub.sid] = pub; trackPublications[pub.sid] = pub;
} }
// Must implement
Future<void> unpublishTrack(String trackSid, {bool notify = false});
Future<void> unpublishAllTracks() async {
final _ = List<TrackPublication>.from(trackPublications.values);
for (final track in _) {
await unpublishTrack(track.sid);
}
}
// Equality operators
// Object is considered equal when sid is equal
@override
int get hashCode => sid.hashCode;
@override
bool operator ==(Object other) => other is Participant && sid == other.sid;
} }
// Convenience extension // Convenience extension
extension ParticipantExt 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(); trackPublications.values.where((e) => e.kind == lk_models.TrackType.VIDEO).toList();
List<TrackPublication> get audioTracks => List<TrackPublication> get audioTracks =>
tracks.values.where((e) => e.kind == lk_models.TrackType.AUDIO).toList(); trackPublications.values.where((e) => e.kind == lk_models.TrackType.AUDIO).toList();
} }
+94 -74
View File
@@ -1,12 +1,18 @@
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:meta/meta.dart';
import '../constants.dart';
import '../events.dart';
import '../extensions.dart';
import '../logger.dart'; import '../logger.dart';
import '../managers/event.dart';
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
import '../signal_client.dart'; import '../signal_client.dart';
import '../track/audio_track.dart'; import '../track/audio_track.dart';
import '../track/remote_track_publication.dart'; import '../track/remote_track_publication.dart';
import '../track/track.dart'; import '../track/track.dart';
import '../track/video_track.dart'; import '../track/video_track.dart';
import '../types.dart';
import 'participant.dart'; import 'participant.dart';
/// Represents other participant in the [Room]. /// Represents other participant in the [Room].
@@ -18,140 +24,154 @@ class RemoteParticipant extends Participant {
RemoteParticipant( RemoteParticipant(
this._client, this._client,
String sid, String sid,
String identity, String identity, {
) : super(sid, identity); required EventsEmitter<RoomEvent> roomEvents,
}) : super(
sid,
identity,
roomEvents: roomEvents,
);
RemoteParticipant.fromInfo( RemoteParticipant.fromInfo(
this._client, this._client,
lk_models.ParticipantInfo info, lk_models.ParticipantInfo info, {
) : super(info.sid, info.identity) { required EventsEmitter<RoomEvent> roomEvents,
}) : super(
info.sid,
info.identity,
roomEvents: roomEvents,
) {
updateFromInfo(info); updateFromInfo(info);
} }
RemoteTrackPublication? getTrackPublication(String sid) { RemoteTrackPublication? getTrackPublication(String sid) {
final pub = tracks[sid]; final pub = trackPublications[sid];
if (pub is RemoteTrackPublication) return pub; if (pub is RemoteTrackPublication) return pub;
} }
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
void addSubscribedMediaTrack( @internal
Future<void> addSubscribedMediaTrack(
rtc.MediaStreamTrack mediaTrack, rtc.MediaStreamTrack mediaTrack,
rtc.MediaStream stream, rtc.MediaStream stream,
String? sid, String trackSid,
) async { ) async {
if (sid == null) { logger.fine('addSubscribedMediaTrack()');
const msg = 'addSubscribedMediaTrack received null sid';
delegate?.onTrackSubscriptionFailed(this, '', msg);
roomDelegate?.onTrackSubscriptionFailed(this, '', msg);
return;
}
var pub = getTrackPublication(sid); // If publication doesn't exist yet...
RemoteTrackPublication? pub = getTrackPublication(trackSid);
if (pub == null) { if (pub == null) {
// we may have received the track prior to metadata. wait up to 3s logger.fine('addSubscribedMediaTrack() pub is null, will wait...');
pub = await _waitForTrackPublication(sid, const Duration(seconds: 3)); // Wait for the metadata to arrive
if (pub == null) { final event = await events.waitFor<TrackPublishedEvent>(
const msg = 'no track metadata found'; filter: (event) => event.participant == this && event.publication.sid == trackSid,
delegate?.onTrackSubscriptionFailed(this, sid, msg); duration: Timeouts.publish,
roomDelegate?.onTrackSubscriptionFailed(this, sid, msg); onTimeout: () => throw TrackSubscriptionExceptionEvent(
return; participant: this,
} sid: trackSid,
reason: TrackSubscribeFailReason.notTrackMetadataFound,
),
);
pub = event.publication;
logger.fine('addSubscribedMediaTrack() did receive pub');
} }
Track? track; // Check if track type is supported, throw if not.
if (![lk_models.TrackType.AUDIO, lk_models.TrackType.VIDEO].contains(pub.kind)) {
throw TrackSubscriptionExceptionEvent(
participant: this,
sid: trackSid,
reason: TrackSubscribeFailReason.unsupportedTrackType,
);
}
// create Track
final Track track;
if (pub.kind == lk_models.TrackType.AUDIO) { if (pub.kind == lk_models.TrackType.AUDIO) {
// audio track
final audioTrack = AudioTrack(pub.name, mediaTrack, stream); final audioTrack = AudioTrack(pub.name, mediaTrack, stream);
audioTrack.start(); audioTrack.start();
track = audioTrack; track = audioTrack;
} else if (pub.kind == lk_models.TrackType.VIDEO) {
track = VideoTrack(pub.name, mediaTrack, stream);
} else { } else {
final msg = 'unsupported track type ${pub.kind}'; // video track
delegate?.onTrackSubscriptionFailed(this, sid, msg); track = VideoTrack(pub.name, mediaTrack, stream);
roomDelegate?.onTrackSubscriptionFailed(this, sid, msg);
return;
} }
pub.track = track; pub.track = track;
addTrackPublication(pub); addTrackPublication(pub);
delegate?.onTrackSubscribed(this, track, pub); [events, roomEvents].emit(TrackSubscribedEvent(
roomDelegate?.onTrackSubscribed(this, track, pub); participant: this,
notifyListeners(); track: track,
publication: pub,
));
} }
/// for internal use /// for internal use
/// {@nodoc} /// {@nodoc}
@override @override
void updateFromInfo(lk_models.ParticipantInfo info) async { @internal
Future<void> updateFromInfo(lk_models.ParticipantInfo info) async {
final hadInfo = hasInfo; final hadInfo = hasInfo;
super.updateFromInfo(info); super.updateFromInfo(info);
// figuring out deltas between tracks // figuring out deltas between tracks
final validPubs = <String, RemoteTrackPublication>{}; final newPubs = <RemoteTrackPublication>{};
final newPubs = <String, RemoteTrackPublication>{};
for (final info in info.tracks) {
final sid = info.sid;
var pub = getTrackPublication(sid);
for (final trackInfo in info.tracks) {
RemoteTrackPublication? pub = getTrackPublication(trackInfo.sid);
if (pub == null) { if (pub == null) {
pub = RemoteTrackPublication(info, this); pub = RemoteTrackPublication(trackInfo, this);
newPubs[sid] = pub; newPubs.add(pub);
addTrackPublication(pub); addTrackPublication(pub);
} else { } else {
pub.updateFromInfo(info); pub.updateFromInfo(trackInfo);
} }
validPubs[sid] = pub;
} }
// notify listeners when it's not a new participant // notify listeners when it's not a new participant
if (hadInfo) { if (hadInfo) {
for (final pub in newPubs.values) { for (final pub in newPubs) {
delegate?.onTrackPublished(this, pub); final event = TrackPublishedEvent(
roomDelegate?.onTrackPublished(this, pub); participant: this,
publication: pub,
);
[events, roomEvents].emit(event);
} }
} }
// remove tracks // unpublish any track that is not in the info
final removeTrackSids = final validSids = info.tracks.map((e) => e.sid);
tracks.values.where((e) => !validPubs.containsKey(e.sid)).map((e) => e.sid).toList(); final removeSids =
trackPublications.values.where((e) => !validSids.contains(e.sid)).map((e) => e.sid);
for (final sid in removeTrackSids) { for (final sid in removeSids) {
await unpublishTrack(sid, true); await unpublishTrack(sid, notify: true);
} }
} }
Future<void> unpublishTrack(String sid, [bool notify = false]) async { @override
logger.finer('Unpublish track sid: $sid, notify: $notify'); Future<void> unpublishTrack(String trackSid, {bool notify = false}) async {
final pub = tracks.remove(sid); logger.finer('Unpublish track sid: $trackSid, notify: $notify');
if (pub == null || pub is! RemoteTrackPublication) return; final pub = trackPublications.remove(trackSid);
if (pub is! RemoteTrackPublication) return;
final track = pub.track; final track = pub.track;
// if has track
if (track != null) { if (track != null) {
await track.stop(); await track.stop();
delegate?.onTrackUnsubscribed(this, track, pub); [events, roomEvents].emit(TrackUnsubscribedEvent(
roomDelegate?.onTrackUnsubscribed(this, track, pub); participant: this,
notifyListeners(); track: track,
publication: pub,
));
} }
if (notify) { if (notify) {
delegate?.onTrackUnpublished(this, pub); [events, roomEvents].emit(TrackUnpublishedEvent(
roomDelegate?.onTrackUnpublished(this, pub); participant: this,
} publication: pub,
} ));
Future<RemoteTrackPublication?> _waitForTrackPublication(String sid, Duration delay) async {
final endTime = DateTime.now().add(delay);
while (DateTime.now().isBefore(endTime)) {
final pub =
await Future<RemoteTrackPublication?>.delayed(const Duration(milliseconds: 100), () {
return getTrackPublication(sid);
});
if (pub != null) return pub;
} }
} }
} }
+192 -251
View File
@@ -2,8 +2,9 @@ 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' as rtc;
import 'classes/change_notifier.dart';
import 'constants.dart';
import 'errors.dart'; import 'errors.dart';
import 'events.dart'; import 'events.dart';
import 'extensions.dart'; import 'extensions.dart';
@@ -14,84 +15,13 @@ import 'participant/local_participant.dart';
import 'participant/participant.dart'; import 'participant/participant.dart';
import 'participant/remote_participant.dart'; import 'participant/remote_participant.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 'rtc_engine.dart'; import 'rtc_engine.dart';
import 'signal_client.dart'; import 'signal_client.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'; import 'types.dart';
enum RoomState {
disconnected,
connected,
reconnecting,
}
/// Delegate for [Room] callbacks
mixin RoomDelegate {
// room level callbacks
/// When the connection to the server has been interrupted and it's attempting
/// to reconnect.
void onReconnecting() {}
/// Connection to room is re-established. All existing state is preserved.
void onReconnected() {}
/// Disconnected from the room
void onDisconnected() {}
/// When a new [RemoteParticipant] joins *after* the current participant has connected
/// It will not fire for participants that are already in the room
void onParticipantConnected(Participant participant) {}
/// When a [RemoteParticipant] leaves the room
void onParticipantDisconnected(Participant participant) {}
/// Active speakers changed. List of speakers are ordered by their audio level.
/// loudest speakers first. This will include the [LocalParticipant] too.
void onActiveSpeakersChanged(List<Participant> participants) {}
// callbacks about participant events
/// Participant metadata is a simple way for app-specific state to be pushed to
/// all users.
/// When RoomService.UpdateParticipantMetadata is called to change a
/// participant's state, *all* participants in the room will fire this event.
void onMetadataChanged(Participant participant) {}
/// A track that was muted, fires on both [RemoteParticipant]s and
/// [LocalParticipant]
void onTrackMuted(Participant participant, TrackPublication publication) {}
/// A track that was unmuted, fires on both [RemoteParticipant]s and
/// [LocalParticipant]
void onTrackUnmuted(Participant participant, TrackPublication publication) {}
/// When a new track is published to room *after* the current participant has
/// joined. It will not fire for tracks that are already published
void onTrackPublished(RemoteParticipant participant, RemoteTrackPublication publication) {}
/// A [RemoteParticipant] has unpublished a track
void onTrackUnpublished(RemoteParticipant participant, RemoteTrackPublication publication) {}
/// The [LocalParticipant] has subscribed to a new track. This event will **always**
/// fire as long as new tracks are ready for use.
void onTrackSubscribed(
RemoteParticipant participant, Track track, RemoteTrackPublication publication) {}
/// A subscribed track is no longer available.
void onTrackUnsubscribed(
RemoteParticipant participant, Track track, RemoteTrackPublication publication) {}
/// Data received from another [RemoteParticipant].
/// Data packets provides the ability to use LiveKit to send/receive arbitrary
/// payloads.
void onDataReceived(RemoteParticipant participant, List<int> data) {}
/// Encountered failure attempting to subscribe to track.
void onTrackSubscriptionFailed(RemoteParticipant participant, String sid, String? message) {}
}
/// Room is the primary construct for LiveKit conferences. It contains a /// Room is the primary construct for LiveKit conferences. It contains a
/// group of [Participant]s, each publishing and subscribing to [Track]s. /// group of [Participant]s, each publishing and subscribing to [Track]s.
/// Notifies changes to its state via two ways, by assigning a delegate, or using /// Notifies changes to its state via two ways, by assigning a delegate, or using
@@ -101,11 +31,12 @@ mixin RoomDelegate {
/// * participant membership changes /// * participant membership changes
/// * active speakers are different /// * active speakers are different
/// {@category Room} /// {@category Room}
class Room extends ChangeNotifier with ParticipantDelegate { class Room extends LKChangeNotifier {
RoomState _connectionState = RoomState.disconnected; // Room is only instantiated if connected, so defaults to connected.
ConnectionState _connectionState = ConnectionState.connected;
/// connection state of the room /// connection state of the room
RoomState get state => _connectionState; ConnectionState get connectionState => _connectionState;
final Map<String, RemoteParticipant> _participants = {}; final Map<String, RemoteParticipant> _participants = {};
@@ -114,13 +45,13 @@ class Room extends ChangeNotifier with ParticipantDelegate {
UnmodifiableMapView(_participants); UnmodifiableMapView(_participants);
/// the current participant /// the current participant
late LocalParticipant localParticipant; late final LocalParticipant localParticipant;
/// name of the room /// name of the room
late String name; late final String name;
/// sid of the room /// sid of the room
late String sid; late final String sid;
List<Participant> _activeSpeakers = []; List<Participant> _activeSpeakers = [];
@@ -128,63 +59,28 @@ class Room extends ChangeNotifier with ParticipantDelegate {
UnmodifiableListView<Participant> get activeSpeakers => UnmodifiableListView<Participant> get activeSpeakers =>
UnmodifiableListView<Participant>(_activeSpeakers); UnmodifiableListView<Participant>(_activeSpeakers);
/// delegate for room events final RTCEngine engine;
RoomDelegate? delegate;
final RTCEngine _engine;
// suppport for multiple event listeners // suppport for multiple event listeners
final events = EventsEmitter<RoomEvent>(); final events = EventsEmitter<RoomEvent>();
late final _engineListener = EventsListener<EngineEvent>(emitter: _engine.events); late final _engineListener = EventsListener<LiveKitEvent>(engine.events);
/// internal use /// internal use
/// {@nodoc} /// {@nodoc}
Room([RTCConfiguration? rtcConfig]) : _engine = RTCEngine(SignalClient(), rtcConfig) { Room._({
_engine.onTrack = _onTrackAdded; required this.engine,
_engine.onICEConnected = _handleICEConnected; required lk_rtc.JoinResponse joinResponse,
_engine.onDisconnected = _handleDisconnect; ConnectOptions? connectOptions,
_engine.onParticipantUpdated = _handleParticipantUpdate; }) {
_engine.onActiveSpeakerUpdated = _handleSpeakerUpdate; //
_engine.onDataMessage = _handleDataPacket; _setUpListeners();
_engine.onRemoteMute = _onRemoteMuteChanged;
_engine.onReconnected = () {
_connectionState = RoomState.connected;
delegate?.onReconnected();
notifyListeners();
};
_engine.onReconnecting = () {
_connectionState = RoomState.reconnecting;
delegate?.onReconnecting();
notifyListeners();
};
}
@override
Future<void> dispose() async {
await events.dispose();
await _engineListener.dispose();
super.dispose();
}
Future<Room> connect(
String url,
String token, {
ConnectOptions? options,
}) async {
final joinResponse = await _engine.join(
url,
token,
options: options,
);
logger.fine('connected to LiveKit server, version: ${joinResponse.serverVersion}');
localParticipant = LocalParticipant( localParticipant = LocalParticipant(
engine: _engine, engine: engine,
info: joinResponse.participant, info: joinResponse.participant,
defaultPublishOptions: options?.defaultPublishOptions, defaultPublishOptions: connectOptions?.defaultPublishOptions,
roomEvents: events,
); );
localParticipant.roomDelegate = this;
sid = joinResponse.room.sid; sid = joinResponse.room.sid;
name = joinResponse.room.name; name = joinResponse.room.name;
@@ -193,89 +89,199 @@ class Room extends ChangeNotifier with ParticipantDelegate {
_getOrCreateRemoteParticipant(info.sid, info); _getOrCreateRemoteParticipant(info.sid, info);
} }
// room is not ready until ICE is connected. // Any event emitted will trigger ChangeNotifier
events.listen((event) {
logger.fine('[RoomEvent] $event, will notifyListeners()');
notifyListeners();
});
}
@override
Future<void> dispose() async {
// dispose local participant
await localParticipant.dispose();
// dispose Room's events emitter
await events.dispose();
// dispose all listeners for RTCEngine
await _engineListener.dispose();
// dispose the engine
await engine.dispose();
super.dispose();
}
static Future<Room> connect(
String url,
String token, {
ConnectOptions? options,
RTCConfiguration? rtcConfig,
}) async {
//
final engine = RTCEngine(
SignalClient(),
rtcConfig,
);
Room? room;
try { try {
await _engineListener.waitFor<EngineIceStateUpdatedEvent>( final joinResponse = await engine.join(
filter: (event) => event.iceState.isConnected(), url,
duration: const Duration(seconds: 5), token,
options: options,
);
logger.fine('Connected to LiveKit server, version: ${joinResponse.serverVersion}');
// create Room first to listen to events
room = Room._(
engine: engine,
joinResponse: joinResponse,
);
logger.fine('Waiting to engine connect...');
// wait until engine is connected
await room._engineListener.waitFor<EngineConnectedEvent>(
duration: Timeouts.connection,
onTimeout: () => throw ConnectException(), onTimeout: () => throw ConnectException(),
); );
return room;
// catch any exception // catch any exception
} catch (_) { } catch (_) {
_connectionState = RoomState.disconnected; // dispose engine if there was any exception while connecting
notifyListeners(); if (room != null) {
// room.dispose will also dispose engine
// pass on the exception await room.dispose();
} else {
await engine.dispose();
}
rethrow; rethrow;
} }
return this;
} }
void _setUpListeners() => _engineListener
..on<EngineConnectedEvent>((event) async {
_connectionState = ConnectionState.connected;
notifyListeners();
})
..on<EngineReconnectedEvent>((event) async {
_connectionState = ConnectionState.connected;
events.emit(const RoomReconnectedEvent());
notifyListeners();
})
..on<EngineReconnectingEvent>((event) async {
_connectionState = ConnectionState.reconnecting;
events.emit(const RoomReconnectingEvent());
notifyListeners();
})
..on<EngineDisconnectedEvent>((event) => _onDisconnectedEvent())
..on<EngineParticipantUpdateEvent>((event) => _onParticipantUpdateEvent(event.participants))
..on<EngineSpeakersUpdateEvent>((event) => _onSpeakerUpdateEvent(event.speakers))
..on<EngineDataPacketReceivedEvent>(_onDataMessageEvent)
..on<EngineRemoteMuteChangedEvent>((event) async {
final track = localParticipant.trackPublications[event.sid];
track?.muted = event.muted;
})
..on<EngineTrackAddedEvent>((event) async {
final idParts = event.stream.id.split('|');
final participantSid = idParts[0];
final trackSid = idParts.elementAtOrNull(1) ?? event.track.id;
final participant = _getOrCreateRemoteParticipant(participantSid, null);
try {
if (trackSid == null || trackSid.isEmpty) {
throw TrackSubscriptionExceptionEvent(
participant: participant,
reason: TrackSubscribeFailReason.invalidServerResponse,
);
}
await participant.addSubscribedMediaTrack(
event.track,
event.stream,
trackSid,
);
} on TrackSubscriptionExceptionEvent catch (event) {
logger.warning('addSubscribedMediaTrack() throwed ${event}');
[participant.roomEvents, participant.events].emit(event);
} catch (exception) {
// We don't want to pass up any exception so catch everything here.
logger.warning('Unknown exception on addSubscribedMediaTrack() ${exception}');
}
});
/// Disconnects from the room, notifying server of disconnection. /// Disconnects from the room, notifying server of disconnection.
Future<void> disconnect() async { Future<void> disconnect() async {
_engine.client.sendLeave(); engine.signalClient.sendLeave();
await _handleDisconnect(); await _onDisconnectedEvent();
} }
Future<void> reconnect() async { Future<void> reconnect() async {
await _engine.reconnect(); await engine.reconnect();
} }
RemoteParticipant _getOrCreateRemoteParticipant(String sid, lk_models.ParticipantInfo? info) { RemoteParticipant _getOrCreateRemoteParticipant(String sid, lk_models.ParticipantInfo? info) {
var participant = _participants[sid]; RemoteParticipant? participant = _participants[sid];
if (participant != null) { if (participant != null) {
return participant; return participant;
} }
if (info == null) { if (info == null) {
participant = RemoteParticipant(_engine.client, sid, ''); participant = RemoteParticipant(
engine.signalClient,
sid,
'',
roomEvents: events,
);
} else { } else {
participant = RemoteParticipant.fromInfo(_engine.client, info); participant = RemoteParticipant.fromInfo(
engine.signalClient,
info,
roomEvents: events,
);
} }
participant.roomDelegate = this;
_participants[sid] = participant; _participants[sid] = participant;
return participant; return participant;
} }
void _handleICEConnected() { Future<void> _onDisconnectedEvent() async {
// _connectCompleter?.complete(this); if (_connectionState == ConnectionState.disconnected) {
// _connectCompleter = null;
_connectionState = RoomState.connected;
notifyListeners();
}
Future<void> _handleDisconnect() async {
if (_connectionState == RoomState.disconnected) {
logger.fine('$objectId: _handleDisconnect() already disconnected'); logger.fine('$objectId: _handleDisconnect() already disconnected');
return; return;
} }
// we need to flag room as disconnected immediately to avoid // we need to flag room as disconnected immediately to avoid
// this method firing multiple times since the following code // this method firing multiple times since the following code
// is being awaited // is being awaited
_connectionState = RoomState.disconnected; _connectionState = ConnectionState.disconnected;
for (final p in _participants.values) { // clean up RemoteParticipants
final tracks = List<TrackPublication>.from(p.tracks.values); for (final _ in _participants.values) {
for (final pub in tracks) { // RemoteParticipant is responsible for disposing resources
await p.unpublishTrack(pub.sid); await _.unpublishAllTracks();
} await _.dispose();
} }
for (final pub in localParticipant.tracks.values) {
await pub.track?.stop();
}
await _engine.close();
_participants.clear(); _participants.clear();
// clean up LocalParticipant
// for (final pub in localParticipant.tracks.values) {
// await pub.track?.stop();
// }
await localParticipant.unpublishAllTracks();
// await localParticipant.dispose();
// localParticipant = null;
await engine.close();
_activeSpeakers.clear(); _activeSpeakers.clear();
notifyListeners(); notifyListeners();
delegate?.onDisconnected(); events.emit(const RoomDisconnectedEvent());
} }
void _handleParticipantUpdate(List<lk_models.ParticipantInfo> updates) { void _onParticipantUpdateEvent(List<lk_models.ParticipantInfo> updates) async {
// trigger change notifier only if list of participants membership is changed // trigger change notifier only if list of participants membership is changed
var hasChanged = false; var hasChanged = false;
for (final info in updates) { for (final info in updates) {
@@ -295,9 +301,9 @@ class Room extends ChangeNotifier with ParticipantDelegate {
if (isNew) { if (isNew) {
hasChanged = true; hasChanged = true;
delegate?.onParticipantConnected(participant); events.emit(ParticipantConnectedEvent(participant: participant));
} else { } else {
participant.updateFromInfo(info); await participant.updateFromInfo(info);
} }
} }
@@ -306,7 +312,7 @@ class Room extends ChangeNotifier with ParticipantDelegate {
} }
} }
void _handleSpeakerUpdate(List<lk_models.SpeakerInfo> speakers) { void _onSpeakerUpdateEvent(List<lk_models.SpeakerInfo> speakers) {
final seenSids = <String>{}; final seenSids = <String>{};
List<Participant> newSpeakers = []; List<Participant> newSpeakers = [];
for (final info in speakers) { for (final info in speakers) {
@@ -339,47 +345,29 @@ class Room extends ChangeNotifier with ParticipantDelegate {
} }
} }
events.emit(ActiveSpeakersChangedEvent(speakers: newSpeakers));
_activeSpeakers = newSpeakers; _activeSpeakers = newSpeakers;
delegate?.onActiveSpeakersChanged(newSpeakers);
notifyListeners(); notifyListeners();
} }
void _handleDataPacket(lk_models.UserPacket packet, lk_models.DataPacket_Kind kind) { void _onDataMessageEvent(EngineDataPacketReceivedEvent dataPacketEvent) {
final participant = participants[packet.participantSid]; // participant may be null if data is sent from Server-API
if (participant == null) { final senderSid = dataPacketEvent.packet.participantSid;
return; RemoteParticipant? senderParticipant;
if (senderSid.isNotEmpty) {
senderParticipant = participants[dataPacketEvent.packet.participantSid];
} }
participant.delegate?.onDataReceived(participant, packet.payload); // participant.delegate?.onDataReceived(participant, event.packet.payload);
delegate?.onDataReceived(participant, packet.payload);
}
void _onRemoteMuteChanged(String sid, bool mute) { final event = DataReceivedEvent(
final track = localParticipant.tracks[sid]; participant: senderParticipant,
// data: dataPacketEvent.packet.payload,
// This will trigger signalClient.sendMuteTrack(sid, mute); );
//
track?.muted = mute;
}
void _onTrackAdded( senderParticipant?.events.emit(event);
rtc.MediaStreamTrack track, events.emit(event);
rtc.MediaStream? stream,
rtc.RTCRtpReceiver? receiver,
) {
if (stream == null) {
// we need the stream to get the track's id
logger.severe('received track without mediastream');
return;
}
final idParts = stream.id.split('|');
final participantSid = idParts[0];
final trackSid = idParts.elementAtOrNull(1) ?? track.id;
final participant = _getOrCreateRemoteParticipant(participantSid, null);
participant.addSubscribedMediaTrack(track, stream, trackSid);
} }
void _handleParticipantDisconnect(String sid) { void _handleParticipantDisconnect(String sid) {
@@ -388,58 +376,11 @@ class Room extends ChangeNotifier with ParticipantDelegate {
return; return;
} }
final toRemove = List<TrackPublication>.from(participant.tracks.values); final toRemove = List<TrackPublication>.from(participant.trackPublications.values);
for (final track in toRemove) { for (final track in toRemove) {
participant.unpublishTrack(track.sid, true); participant.unpublishTrack(track.sid, notify: true);
} }
delegate?.onParticipantDisconnected(participant);
}
//----------------- forward participant delegate calls ---------------------// events.emit(ParticipantDisconnectedEvent(participant: participant));
@override
void onMetadataChanged(Participant participant) {
delegate?.onMetadataChanged(participant);
}
@override
void onTrackMuted(Participant participant, TrackPublication publication) {
delegate?.onTrackMuted(participant, publication);
}
@override
void onTrackUnmuted(Participant participant, TrackPublication publication) {
delegate?.onTrackUnmuted(participant, publication);
}
@override
void onTrackPublished(RemoteParticipant participant, RemoteTrackPublication publication) {
delegate?.onTrackPublished(participant, publication);
}
@override
void onTrackUnpublished(RemoteParticipant participant, RemoteTrackPublication publication) {
delegate?.onTrackUnpublished(participant, publication);
}
@override
void onTrackSubscribed(
RemoteParticipant participant, Track track, RemoteTrackPublication publication) {
delegate?.onTrackSubscribed(participant, track, publication);
}
@override
void onTrackUnsubscribed(
RemoteParticipant participant, Track track, RemoteTrackPublication publication) {
delegate?.onTrackUnsubscribed(participant, track, publication);
}
// omitted because data dispatching is handled in _handleDataPacket
@override
void onDataReceived(RemoteParticipant participant, List<int> data) {}
@override
void onTrackSubscriptionFailed(RemoteParticipant participant, String sid, String? message) {
delegate?.onTrackSubscriptionFailed(participant, sid, message);
} }
} }
+138 -173
View File
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'constants.dart';
import 'errors.dart'; import 'errors.dart';
import 'events.dart'; import 'events.dart';
import 'extensions.dart'; import 'extensions.dart';
@@ -14,31 +15,15 @@ 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 'transport.dart'; import 'transport.dart';
import 'types.dart'; import 'types.dart';
typedef GenericCallback = void Function(); class RTCEngine {
typedef TrackCallback = void Function(
rtc.MediaStreamTrack track,
rtc.MediaStream? stream,
rtc.RTCRtpReceiver? receiver,
);
typedef ParticipantUpdateCallback = void Function(List<lk_models.ParticipantInfo> participants);
typedef ActiveSpeakerChangedCallback = void Function(List<lk_models.SpeakerInfo> speakers);
typedef DataPacketCallback = void Function(
lk_models.UserPacket packet, lk_models.DataPacket_Kind kind);
typedef RemoteMuteCallback = void Function(String sid, bool mute);
class RTCEngine with SignalClientDelegate {
static const _lossyDCLabel = '_lossy'; static const _lossyDCLabel = '_lossy';
static const _reliableDCLabel = '_reliable'; static const _reliableDCLabel = '_reliable';
static const _maxReconnectAttempts = 5; 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; final SignalClient signalClient;
// config for RTCPeerConnection // config for RTCPeerConnection
final RTCConfiguration? rtcConfig; final RTCConfiguration? rtcConfig;
@@ -67,42 +52,44 @@ class RTCEngine with SignalClientDelegate {
// server-provided ice servers // server-provided ice servers
List<lk_rtc.ICEServer> _providedIceServers = []; List<lk_rtc.ICEServer> _providedIceServers = [];
// delegate methods
GenericCallback? onICEConnected;
TrackCallback? onTrack;
ParticipantUpdateCallback? onParticipantUpdated;
ActiveSpeakerChangedCallback? onActiveSpeakerUpdated;
DataPacketCallback? onDataMessage;
RemoteMuteCallback? onRemoteMute;
GenericCallback? onReconnecting;
GenericCallback? onReconnected;
GenericCallback? onDisconnected;
//
// internal // internal
//
final Map<String, Completer<lk_models.TrackInfo>> _pendingTrackResolvers = {};
int _reconnectAttempts = 0; int _reconnectAttempts = 0;
// to complete join request
Completer<lk_rtc.JoinResponse>? _joinCompleter;
final events = EventsEmitter<EngineEvent>(); final events = EventsEmitter<EngineEvent>();
late final _signalListener = EventsListener(signalClient.events, synchronized: true);
final delays = CancelableDelayManager(); final delays = CancelableDelayManager();
// late final Timer _statsTimer;
RTCEngine( RTCEngine(
this.client, this.signalClient,
this.rtcConfig, this.rtcConfig,
) { ) {
client.delegate = this;
if (kDebugMode) { if (kDebugMode) {
events.listen((event) => logger.fine('[LISTENER] $objectId ${event.runtimeType}')); // log all EngineEvents
events.on<EngineIceStateUpdatedEvent>( events.listen((event) => logger.fine('[EngineEvent] $objectId ${event.runtimeType}'));
(event) => logger.fine('[LISTENER] event is a EngineIceStateUpdatedEvent'));
} }
_setUpListeners();
// _statsTimer = Timer.periodic(const Duration(seconds: 1), _onStatTimer);
} }
Future<void> dispose() async {
await events.dispose();
await _signalListener.dispose();
}
// void _onStatTimer(Timer _) async {
// //
// final stats = await publisher?.pc.getStats();
// if (stats == null || stats.isEmpty) return;
// for (final s in stats) {
// logger.fine('STATS ${s.values}');
// }
// }
Future<lk_rtc.JoinResponse> join( Future<lk_rtc.JoinResponse> join(
String url, String url,
String token, { String token, {
@@ -111,18 +98,16 @@ class RTCEngine with SignalClientDelegate {
this.url = url; this.url = url;
this.token = token; this.token = token;
final completer = Completer<lk_rtc.JoinResponse>(); // connect to rtc server
_joinCompleter = completer; await signalClient.connect(url, token, options: options);
await client.join(url, token, options: options); // wait for join response
final event = await _signalListener.waitFor<SignalConnectedEvent>(
duration: Timeouts.connection,
onTimeout: () => throw ConnectException(),
);
// if it's not complete after 5 seconds, fail return event.response;
Timer(_connectionTimeout, () {
_joinCompleter?.completeError(ConnectException());
_joinCompleter = null;
});
return completer.future;
} }
Future<void> close() async { Future<void> close() async {
@@ -133,12 +118,12 @@ class RTCEngine with SignalClientDelegate {
} }
isClosed = true; isClosed = true;
// _statsTimer.cancel();
// cancel events // cancel events
await _primaryIceStateListener?.call(); await _primaryIceStateListener?.call();
_primaryIceStateListener = null; _primaryIceStateListener = null;
await events.dispose();
// cancel all ongoing delays // cancel all ongoing delays
await delays.dispose(); await delays.dispose();
@@ -149,7 +134,7 @@ class RTCEngine with SignalClientDelegate {
await subscriber?.dispose(); await subscriber?.dispose();
subscriber = null; subscriber = null;
client.close(); signalClient.close();
} }
Future<lk_models.TrackInfo> addTrack({ Future<lk_models.TrackInfo> addTrack({
@@ -158,16 +143,17 @@ class RTCEngine with SignalClientDelegate {
required lk_models.TrackType kind, required lk_models.TrackType kind,
TrackDimension? dimension, TrackDimension? dimension,
}) async { }) async {
if (_pendingTrackResolvers[cid] != null) { // send request to add track
throw TrackPublishException('a track with the same CID has already been published'); signalClient.sendAddTrack(cid: cid, name: name, type: kind, dimension: dimension);
}
final completer = Completer<lk_models.TrackInfo>(); // wait for response, or timeout
_pendingTrackResolvers[cid] = completer; final event = await _signalListener.waitFor<SignalLocalTrackPublishedEvent>(
filter: (event) => event.cid == cid,
duration: Timeouts.publish,
onTimeout: () => throw TrackPublishException(),
);
client.sendAddTrack(cid: cid, name: name, type: kind, dimension: dimension); return event.track;
return completer.future;
} }
Future<void> negotiate({bool? iceRestart}) async { Future<void> negotiate({bool? iceRestart}) async {
@@ -214,7 +200,7 @@ class RTCEngine with SignalClientDelegate {
await events.waitFor<EnginePublisherIceStateUpdatedEvent>( await events.waitFor<EnginePublisherIceStateUpdatedEvent>(
filter: (event) => event.iceState.isConnected(), filter: (event) => event.iceState.isConnected(),
duration: _maxICEConnectTimeout, duration: Timeouts.iceConnection,
); );
logger.fine('[PUBLISHER] connected'); logger.fine('[PUBLISHER] connected');
@@ -234,14 +220,13 @@ class RTCEngine with SignalClientDelegate {
} }
if (_reconnectAttempts == 0) { if (_reconnectAttempts == 0) {
onReconnecting?.call(); events.emit(const EngineReconnectingEvent());
events.emit(EngineReconnectingEvent());
} }
_reconnectAttempts++; _reconnectAttempts++;
try { try {
isReconnecting = true; isReconnecting = true;
await client.reconnect(url, token); await signalClient.reconnect(url, token);
if (publisher == null || subscriber == null) { if (publisher == null || subscriber == null) {
throw UnexpectedStateException('publisher or subscribers is null'); throw UnexpectedStateException('publisher or subscribers is null');
@@ -260,12 +245,12 @@ class RTCEngine with SignalClientDelegate {
await events.waitFor<EngineIceStateUpdatedEvent>( await events.waitFor<EngineIceStateUpdatedEvent>(
filter: (event) => event.isPrimary && event.iceState.isConnected(), filter: (event) => event.isPrimary && event.iceState.isConnected(),
duration: _iceRestartTimeout, duration: Timeouts.iceRestart,
); );
} }
logger.fine('reconnect: success'); logger.fine('reconnect: success');
events.emit(EngineReconnectedEvent()); events.emit(const EngineReconnectedEvent());
_reconnectAttempts = 0; _reconnectAttempts = 0;
// don't catch and pass up any exception // don't catch and pass up any exception
@@ -293,17 +278,17 @@ class RTCEngine with SignalClientDelegate {
publisher?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) { publisher?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) {
logger.fine('publisher onIceCandidate'); logger.fine('publisher onIceCandidate');
client.sendIceCandidate(candidate, lk_rtc.SignalTarget.PUBLISHER); signalClient.sendIceCandidate(candidate, lk_rtc.SignalTarget.PUBLISHER);
}; };
subscriber?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) { subscriber?.pc.onIceCandidate = (rtc.RTCIceCandidate candidate) {
logger.fine('subscriber onIceCandidate'); logger.fine('subscriber onIceCandidate');
client.sendIceCandidate(candidate, lk_rtc.SignalTarget.SUBSCRIBER); signalClient.sendIceCandidate(candidate, lk_rtc.SignalTarget.SUBSCRIBER);
}; };
publisher?.onOffer = (offer) { publisher?.onOffer = (offer) {
logger.fine('publisher onOffer'); logger.fine('publisher onOffer');
client.sendOffer(offer); signalClient.sendOffer(offer);
}; };
// in subscriber primary mode, server side opens sub data channels. // in subscriber primary mode, server side opens sub data channels.
@@ -336,10 +321,9 @@ class RTCEngine with SignalClientDelegate {
if (!iceConnected) { if (!iceConnected) {
iceConnected = true; iceConnected = true;
if (isReconnecting) { if (isReconnecting) {
onReconnected?.call(); events.emit(const EngineReconnectedEvent());
} else { } else {
onICEConnected?.call(); events.emit(const EngineConnectedEvent());
events.emit(EngineConnectedEvent());
} }
} }
} else if (event.iceState == rtc.RTCIceConnectionState.RTCIceConnectionStateFailed) { } else if (event.iceState == rtc.RTCIceConnectionState.RTCIceConnectionStateFailed) {
@@ -352,10 +336,16 @@ class RTCEngine with SignalClientDelegate {
}); });
subscriber?.pc.onTrack = (rtc.RTCTrackEvent event) { subscriber?.pc.onTrack = (rtc.RTCTrackEvent event) {
onTrack?.call(event.track, event.streams.firstOrNull, event.receiver); final stream = event.streams.firstOrNull;
events.emit(EngineMediaTrackAddedEvent( if (stream == null) {
// we need the stream to get the track's id
logger.severe('received track without mediastream');
return;
}
events.emit(EngineTrackAddedEvent(
track: event.track, track: event.track,
stream: event.streams.firstOrNull, stream: stream,
receiver: event.receiver, receiver: event.receiver,
)); ));
}; };
@@ -405,11 +395,9 @@ class RTCEngine with SignalClientDelegate {
final dp = lk_models.DataPacket.fromBuffer(message.binary); final dp = lk_models.DataPacket.fromBuffer(message.binary);
if (dp.whichValue() == lk_models.DataPacket_Value.speaker) { if (dp.whichValue() == lk_models.DataPacket_Value.speaker) {
// Speaker packet // Speaker packet
onActiveSpeakerUpdated?.call(dp.speaker.speakers);
events.emit(EngineSpeakersUpdateEvent(speakers: dp.speaker.speakers)); events.emit(EngineSpeakersUpdateEvent(speakers: dp.speaker.speakers));
} else if (dp.whichValue() == lk_models.DataPacket_Value.user) { } else if (dp.whichValue() == lk_models.DataPacket_Value.user) {
// User packet // User packet
onDataMessage?.call(dp.user, dp.kind);
events.emit(EngineDataPacketReceivedEvent( events.emit(EngineDataPacketReceivedEvent(
packet: dp.user, packet: dp.user,
kind: dp.kind, kind: dp.kind,
@@ -424,8 +412,7 @@ class RTCEngine with SignalClientDelegate {
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(); events.emit(const EngineDisconnectedEvent());
events.emit(EngineDisconnectedEvent());
return; return;
} }
@@ -447,104 +434,82 @@ class RTCEngine with SignalClientDelegate {
//------------------ SignalClient Delegate methods -------------------------// //------------------ SignalClient Delegate methods -------------------------//
@override void _setUpListeners() => _signalListener
Future<void> onConnected(lk_rtc.JoinResponse response) async { ..on<SignalConnectedEvent>((event) async {
// create peer connections // create peer connections
isClosed = false; isClosed = false;
_subscriberPrimary = response.subscriberPrimary; _subscriberPrimary = event.response.subscriberPrimary;
_providedIceServers = response.iceServers; _providedIceServers = event.response.iceServers;
logger.fine('onConnected subscriberPrimary: ${_subscriberPrimary}, ' logger.fine('onConnected subscriberPrimary: ${_subscriberPrimary}, '
'serverVersion: ${response.serverVersion}, ' 'serverVersion: ${event.response.serverVersion}, '
'iceServers: ${response.iceServers}'); 'iceServers: ${event.response.iceServers}');
await _configurePeerConnections(); await _configurePeerConnections();
if (!_subscriberPrimary) { if (!_subscriberPrimary) {
// for subscriberPrimary, we negotiate when necessary (lazy) // for subscriberPrimary, we negotiate when necessary (lazy)
await negotiate(); await negotiate();
} }
_joinCompleter?.complete(Future.value(response)); // _joinCompleter?.complete(Future.value(event.response));
_joinCompleter = null; // _joinCompleter = null;
} })
..on<SignalCloseEvent>((_) async {
await _onDisconnected('signal');
})
..on<SignalOfferEvent>((event) async {
if (subscriber == null) {
return;
}
@override logger.fine('received server offer(type: ${event.sd.type}, '
Future<void> onClose([String? reason]) async { '${subscriber!.pc.signalingState})');
await _onDisconnected('signal');
}
@override await subscriber!.setRemoteDescription(event.sd);
Future<void> onOffer(rtc.RTCSessionDescription sd) async {
if (subscriber == null) {
return;
}
logger.fine('received server offer(type: ${sd.type}, ${subscriber!.pc.signalingState})'); final answer = await subscriber!.pc.createAnswer();
logger.fine('Created answer');
await subscriber!.setRemoteDescription(sd); logger.finer('sdp: ${answer.sdp}');
await subscriber!.pc.setLocalDescription(answer);
final answer = await subscriber!.pc.createAnswer(); signalClient.sendAnswer(answer);
logger.fine('Created answer'); })
logger.finer('sdp: ${answer.sdp}'); ..on<SignalAnswerEvent>((event) async {
await subscriber!.pc.setLocalDescription(answer); if (publisher == null) {
client.sendAnswer(answer); return;
} }
logger.fine('received answer (type: ${event.sd.type})');
@override logger.finer('sdp: ${event.sd.sdp}');
Future<void> onAnswer(rtc.RTCSessionDescription sd) async { await publisher!.setRemoteDescription(event.sd);
if (publisher == null) { })
return; ..on<SignalTrickleEvent>((event) async {
} if (publisher == null || subscriber == null) {
logger.fine('received answer (type: ${sd.type})'); logger.warning('Received ${SignalTrickleEvent} but publisher or subscriber was null.');
logger.finer('sdp: ${sd.sdp}'); return;
await publisher!.setRemoteDescription(sd); }
} logger.fine('got ICE candidate from peer');
if (event.target == lk_rtc.SignalTarget.SUBSCRIBER) {
@override await subscriber!.addIceCandidate(event.candidate);
Future<void> onTrickle(rtc.RTCIceCandidate candidate, lk_rtc.SignalTarget target) async { } else if (event.target == lk_rtc.SignalTarget.PUBLISHER) {
if (publisher == null || subscriber == null) { await publisher!.addIceCandidate(event.candidate);
return; }
} })
logger.fine('got ICE candidate from peer'); ..on<SignalParticipantUpdateEvent>((event) async {
if (target == lk_rtc.SignalTarget.SUBSCRIBER) { events.emit(EngineParticipantUpdateEvent(participants: event.updates));
await subscriber!.addIceCandidate(candidate); })
} else if (target == lk_rtc.SignalTarget.PUBLISHER) { // ..on<SignalLocalTrackPublishedEvent>((event) async {
await publisher!.addIceCandidate(candidate); // final completer = _pendingTrackResolvers.remove(event.cid);
} // completer?.complete(event.track);
} // })
..on<SignalActiveSpeakersChangedEvent>((event) async {
@override events.emit(EngineSpeakersUpdateEvent(speakers: event.speakers));
Future<void> onParticipantUpdate(List<lk_models.ParticipantInfo> updates) async { })
onParticipantUpdated?.call(updates); ..on<SignalLeaveEvent>((event) async {
events.emit(EngineParticipantUpdateEvent(participants: updates)); await close();
} events.emit(const EngineDisconnectedEvent());
})
@override ..on<SignalMuteTrackEvent>((event) => events.emit(EngineRemoteMuteChangedEvent(
Future<void> onLocalTrackPublished(lk_rtc.TrackPublishedResponse response) async { sid: event.sid,
final completer = _pendingTrackResolvers.remove(response.cid); muted: event.muted,
completer?.complete(Future.value(response.track)); )));
}
@override
Future<void> onActiveSpeakersChanged(List<lk_models.SpeakerInfo> speakers) async {
onActiveSpeakerUpdated?.call(speakers);
events.emit(EngineSpeakersUpdateEvent(speakers: speakers));
}
@override
Future<void> onLeave(lk_rtc.LeaveRequest req) async {
await close();
onDisconnected?.call();
events.emit(EngineDisconnectedEvent());
}
@override
Future<void> onMuteTrack(lk_rtc.MuteTrackRequest req) async {
onRemoteMute?.call(req.sid, req.muted);
events.emit(EngineRemoteMuteChangedEvent(
sid: req.sid,
muted: req.muted,
));
}
} }
+57 -75
View File
@@ -1,59 +1,40 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; 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:synchronized/synchronized.dart' as sync;
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 '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 'types.dart';
import 'utils.dart'; import 'utils.dart';
import 'ws/interface.dart'; import 'ws/interface.dart';
mixin SignalClientDelegate {
// initial connection established
Future<void> onConnected(lk_rtc.JoinResponse response);
// websocket has closed
Future<void> onClose([String? reason]);
// when a server offer is received
Future<void> onOffer(rtc.RTCSessionDescription sd);
// when an answer from server is received
Future<void> onAnswer(rtc.RTCSessionDescription sd);
// when server has a new ICE candidate
Future<void> onTrickle(rtc.RTCIceCandidate candidate, lk_rtc.SignalTarget target);
// participant has changed
Future<void> onParticipantUpdate(List<lk_models.ParticipantInfo> updates);
// when a track has been added successfully
Future<void> onLocalTrackPublished(lk_rtc.TrackPublishedResponse response);
// active speaker has changed
Future<void> onActiveSpeakersChanged(List<lk_models.SpeakerInfo> speakers);
// when server sends this client a leave message
Future<void> onLeave(lk_rtc.LeaveRequest req);
// explicit mute track
Future<void> onMuteTrack(lk_rtc.MuteTrackRequest req);
}
class SignalClient { class SignalClient {
final _lock = sync.Lock(); final events = EventsEmitter<SignalEvent>();
final ProtocolVersion protocol;
ProtocolVersion protocol;
SignalClientDelegate? delegate;
bool _connected = false; bool _connected = false;
LiveKitWebSocket? _ws; LiveKitWebSocket? _ws;
SignalClient({ SignalClient({
this.protocol = ProtocolVersion.protocol3, this.protocol = ProtocolVersion.protocol3,
}); }) {
events.listen((event) {
logger.fine('[SignalEvent] $event');
});
}
bool get connected => _connected; bool get connected => _connected;
Future<void> join( Future<void> connect(
String uriString, String uriString,
String token, { String token, {
ConnectOptions? options, ConnectOptions? options,
@@ -202,7 +183,7 @@ class SignalClient {
void _sendRequest(lk_rtc.SignalRequest req) { void _sendRequest(lk_rtc.SignalRequest req) {
if (_ws == null) { if (_ws == null) {
log('could not send message, not connected'); logger.warning('could not send message, not connected');
return; return;
} }
@@ -214,48 +195,49 @@ class SignalClient {
if (message is! List<int>) return; if (message is! List<int>) return;
final msg = lk_rtc.SignalResponse.fromBuffer(message); final msg = lk_rtc.SignalResponse.fromBuffer(message);
// Ensure previous delegate method's future is completed switch (msg.whichMessage()) {
// before calling another method case lk_rtc.SignalResponse_Message.join:
await _lock.synchronized(() async { if (!_connected) {
// _connected = true;
switch (msg.whichMessage()) { events.emit(SignalConnectedEvent(response: msg.join));
case lk_rtc.SignalResponse_Message.join: }
if (!_connected) { break;
_connected = true; case lk_rtc.SignalResponse_Message.answer:
await delegate?.onConnected(msg.join); events.emit(SignalAnswerEvent(sd: msg.answer.toSDKType()));
} break;
break; case lk_rtc.SignalResponse_Message.offer:
case lk_rtc.SignalResponse_Message.answer: events.emit(SignalOfferEvent(sd: msg.offer.toSDKType()));
await delegate?.onAnswer(msg.answer.toSDKType()); break;
break; case lk_rtc.SignalResponse_Message.trickle:
case lk_rtc.SignalResponse_Message.offer: events.emit(SignalTrickleEvent(
await delegate?.onOffer(msg.offer.toSDKType()); candidate: RTCIceCandidateExt.fromJson(msg.trickle.candidateInit),
break; target: msg.trickle.target,
case lk_rtc.SignalResponse_Message.trickle: ));
await delegate?.onTrickle( break;
RTCIceCandidateExt.fromJson(msg.trickle.candidateInit), case lk_rtc.SignalResponse_Message.update:
msg.trickle.target, events.emit(SignalParticipantUpdateEvent(updates: msg.update.participants));
); break;
break; case lk_rtc.SignalResponse_Message.trackPublished:
case lk_rtc.SignalResponse_Message.update: events.emit(SignalLocalTrackPublishedEvent(
await delegate?.onParticipantUpdate(msg.update.participants); cid: msg.trackPublished.cid,
break; track: msg.trackPublished.track,
case lk_rtc.SignalResponse_Message.trackPublished: ));
await delegate?.onLocalTrackPublished(msg.trackPublished); break;
break; case lk_rtc.SignalResponse_Message.speaker:
case lk_rtc.SignalResponse_Message.speaker: events.emit(SignalActiveSpeakersChangedEvent(speakers: msg.speaker.speakers));
await delegate?.onActiveSpeakersChanged(msg.speaker.speakers); break;
break; case lk_rtc.SignalResponse_Message.leave:
case lk_rtc.SignalResponse_Message.leave: events.emit(SignalLeaveEvent(canReconnect: msg.leave.canReconnect));
await delegate?.onLeave(msg.leave); break;
break; case lk_rtc.SignalResponse_Message.mute:
case lk_rtc.SignalResponse_Message.mute: events.emit(SignalMuteTrackEvent(
await delegate?.onMuteTrack(msg.mute); sid: msg.mute.sid,
break; muted: msg.mute.muted,
default: ));
log('unsupported message: ' + json.encode(msg)); break;
} default:
}); logger.warning('unsupported message: ' + json.encode(msg));
}
} }
void _handleError(dynamic error) { void _handleError(dynamic error) {
@@ -266,6 +248,6 @@ class SignalClient {
if (!_connected) return; if (!_connected) return;
_ws = null; _ws = null;
_connected = false; _connected = false;
delegate?.onClose(); events.emit(const SignalCloseEvent());
} }
} }
+14 -6
View File
@@ -1,4 +1,7 @@
import '../events.dart';
import '../extensions.dart';
import '../logger.dart'; import '../logger.dart';
import '../managers/event.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';
@@ -23,15 +26,20 @@ class LocalTrackPublication extends TrackPublication {
super.muted = val; super.muted = val;
track?.mediaStreamTrack.enabled = !val; track?.mediaStreamTrack.enabled = !val;
_participant.engine.client.sendMuteTrack(sid, val); _participant.engine.signalClient.sendMuteTrack(sid, val);
if (val) { if (val) {
_participant.delegate?.onTrackMuted(_participant, this); // Track muted
_participant.roomDelegate?.onTrackMuted(_participant, this); [_participant.events, _participant.roomEvents].emit(TrackMutedEvent(
participant: _participant,
track: this,
));
} else { } else {
_participant.delegate?.onTrackUnmuted(_participant, this); // Track un-muted
_participant.roomDelegate?.onTrackUnmuted(_participant, this); [_participant.events, _participant.roomEvents].emit(TrackUnmutedEvent(
participant: _participant,
track: this,
));
} }
_participant.muteChanged();
} }
} }
+14 -5
View File
@@ -1,7 +1,11 @@
import 'package:livekit_client/livekit_client.dart';
import '../participant/remote_participant.dart'; import '../participant/remote_participant.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.dart'; import 'track.dart';
import '../extensions.dart';
import 'track_publication.dart'; import 'track_publication.dart';
/// Represents a track publication from a RemoteParticipant. Provides methods to /// Represents a track publication from a RemoteParticipant. Provides methods to
@@ -50,16 +54,21 @@ class RemoteTrackPublication extends TrackPublication {
} }
super.muted = val; super.muted = val;
if (val) { if (val) {
_participant.delegate?.onTrackMuted(_participant, this); // Track muted
_participant.roomDelegate?.onTrackMuted(_participant, this); [_participant.events, _participant.roomEvents].emit(TrackMutedEvent(
participant: _participant,
track: this,
));
} else { } else {
_participant.delegate?.onTrackUnmuted(_participant, this); // Track un-muted
_participant.roomDelegate?.onTrackUnmuted(_participant, this); [_participant.events, _participant.roomEvents].emit(TrackUnmutedEvent(
participant: _participant,
track: this,
));
} }
if (subscribed) { if (subscribed) {
track?.mediaStreamTrack.enabled = !val; track?.mediaStreamTrack.enabled = !val;
} }
_participant.muteChanged();
} }
RemoteTrackPublication( RemoteTrackPublication(
+12 -11
View File
@@ -1,28 +1,29 @@
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/src/classes/change_notifier.dart';
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;
class TrackDimension {
int width;
int height;
TrackDimension(this.width, this.height);
}
/// Wrapper around a MediaStreamTrack with additional metadata. /// Wrapper around a MediaStreamTrack with additional metadata.
class Track { /// Base for [AudioTrack] and [VideoTrack],
/// can not be instantiated directly.
abstract class Track extends LKChangeNotifier {
static const cameraName = 'camera'; static const cameraName = 'camera';
static const screenShareName = 'screen'; static const screenShareName = 'screen';
String name; final String name;
lk_models.TrackType kind; final lk_models.TrackType kind;
rtc.MediaStreamTrack mediaStreamTrack; rtc.MediaStreamTrack mediaStreamTrack;
String? sid; String? sid;
rtc.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!;
+18 -4
View File
@@ -1,13 +1,19 @@
import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_models.pb.dart' as lk_models;
import '../types.dart';
import 'track.dart'; import 'track.dart';
/// Represents a track that's published to the server. This class contains /// Represents a track that's published to the server. This class contains
/// metadata associated with tracks. /// metadata associated with tracks.
class TrackPublication { ///
/// Base for [RemoteTrackPublication] and [LocalTrackPublication],
/// can not be instantiated directly.
abstract class TrackPublication {
final String name;
final String sid;
final lk_models.TrackType kind;
Track? track; Track? track;
String name;
String sid;
lk_models.TrackType kind;
bool muted = false; bool muted = false;
bool simulcasted = false; bool simulcasted = false;
TrackDimension? dimension; TrackDimension? dimension;
@@ -31,4 +37,12 @@ class TrackPublication {
dimension = TrackDimension(info.width, info.height); dimension = TrackDimension(info.width, info.height);
} }
} }
// Equality operators
// Object is considered equal when sid is equal
@override
int get hashCode => sid.hashCode;
@override
bool operator ==(Object other) => other is TrackPublication && sid == other.sid;
} }
+1 -3
View File
@@ -1,12 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; 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 {
rtc.MediaStream _mediaStream; rtc.MediaStream _mediaStream;
VideoTrack( VideoTrack(
+3 -2
View File
@@ -2,10 +2,11 @@ import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'constants.dart';
import 'extensions.dart';
import 'logger.dart'; import 'logger.dart';
import 'types.dart'; import 'types.dart';
import 'utils.dart'; import 'utils.dart';
import 'extensions.dart';
typedef PCTransportOnOffer = void Function(rtc.RTCSessionDescription offer); typedef PCTransportOnOffer = void Function(rtc.RTCSessionDescription offer);
@@ -31,7 +32,7 @@ class PCTransport {
late final negotiate = Utils.createDebounceFunc( late final negotiate = Utils.createDebounceFunc(
() => createAndSendOffer(), () => createAndSendOffer(),
cancelFunc: (f) => _cancelDebounce = f, cancelFunc: (f) => _cancelDebounce = f,
wait: const Duration(milliseconds: 100), wait: Timeouts.debounce,
); );
Future<void> dispose() async { Future<void> dispose() async {
+34 -4
View File
@@ -1,18 +1,37 @@
//
// LiveKit
//
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'extensions.dart'; import 'extensions.dart';
typedef CancelListenFunc = Function(); typedef CancelListenFunc = Function();
enum ProtocolVersion {
protocol2,
protocol3,
}
enum ConnectionState {
disconnected,
connected,
reconnecting,
}
enum Reliability { enum Reliability {
reliable, reliable,
lossy, lossy,
} }
enum CloseReason {
network,
// ...
}
enum TrackSubscribeFailReason {
invalidServerResponse,
notTrackMetadataFound,
unsupportedTrackType,
// ...
}
enum RTCIceTransportPolicy { enum RTCIceTransportPolicy {
all, all,
relay, relay,
@@ -89,3 +108,14 @@ class RTCIceServer {
if (credential?.isNotEmpty ?? false) 'credential': credential, if (credential?.isNotEmpty ?? false) 'credential': credential,
}; };
} }
@immutable
class TrackDimension {
final int width;
final int height;
const TrackDimension(
this.width,
this.height,
);
}
+2 -16
View File
@@ -1,25 +1,11 @@
//
//
//
import 'dart:async'; import 'dart:async';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'extensions.dart';
import 'options.dart'; import 'options.dart';
import 'track/options.dart'; import 'track/options.dart';
import 'types.dart';
enum ProtocolVersion {
protocol2,
protocol3,
}
extension ProtocolVersionExt on ProtocolVersion {
String toStringValue() => {
ProtocolVersion.protocol2: '2',
ProtocolVersion.protocol3: '3',
}[this]!;
}
extension UriExt on Uri { extension UriExt on Uri {
bool get isSecureScheme => ['https', 'wss'].contains(scheme); bool get isSecureScheme => ['https', 'wss'].contains(scheme);
+13 -1
View File
@@ -33,7 +33,19 @@ class LiveKitWebSocketIO implements LiveKitWebSocket {
} }
@override @override
void send(List<int> data) => _ws.add(data); void send(List<int> data) {
// 0 CONNECTING
// 1 OPEN
// 2 CLOSING
// 3 CLOSED
if (_ws.readyState == 1) {
try {
_ws.add(data);
} catch (e) {
//
}
}
}
static Future<LiveKitWebSocketIO> connect( static Future<LiveKitWebSocketIO> connect(
Uri uri, [ Uri uri, [
+8 -6
View File
@@ -98,9 +98,11 @@ packages:
flutter_webrtc: flutter_webrtc:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_webrtc path: "."
url: "https://pub.dartlang.org" ref: use-custom-webrtc-build
source: hosted resolved-ref: "4942e7faec2e5775d35c42e22c2929ca6ca53769"
url: "https://github.com/livekit/flutter-webrtc"
source: git
version: "0.6.7" version: "0.6.7"
http: http:
dependency: "direct main" dependency: "direct main"
@@ -157,7 +159,7 @@ packages:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.4" version: "2.0.5"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -311,5 +313,5 @@ packages:
source: hosted source: hosted
version: "0.2.0" version: "0.2.0"
sdks: sdks:
dart: ">=2.13.0 <3.0.0" dart: ">=2.14.0 <3.0.0"
flutter: ">=2.0.0" flutter: ">=2.5.0"
+11 -1
View File
@@ -10,13 +10,23 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_webrtc: ^0.6.7
http: ^0.13.3 http: ^0.13.3
logging: ^1.0.2 logging: ^1.0.2
uuid: ^3.0.4 uuid: ^3.0.4
synchronized: ^3.0.0 synchronized: ^3.0.0
protobuf: ^2.0.0 protobuf: ^2.0.0
flutter_webrtc:
git:
url: https://github.com/livekit/flutter-webrtc
ref: use-custom-webrtc-build
# ^0.6.7
# path: ../../repos_livekit/flutter-webrtc/
# This will use custom webrtc build from
# https://github.com/webrtc-sdk/Specs/releases
# protobuf: # protobuf:
# git: # git:
# url: https://github.com/google/protobuf.dart.git # url: https://github.com/google/protobuf.dart.git