Simulcast, Screen sharing & Various improvements (#4)

* Respect `RTCIceTransportPolicy` enum and organize

* Simplify syntax where possible etc.

* Combine `VideoPreset` and `VideoPresets`

* Default values for `ConnectOptions`

* Build URI instead of String manipulation

* Slight modifications to Exception

* `LiveKitTheme` for example

* `VideoEncoding` class

* Organize imports

* First simulcast implementation

* Remove unnecessary try-catches

* Update Android settings

* Remember uri and token

* example improvements

* `fit` parameter for VideoTrackRenderer

* Simulcast option for example

* Pass `defaultPublishOptions`

* Show only `VideoQuality`

* Pass tests

* Better buildUri logic

* Named parameter to positional

* Explicit imports

* `VideoParameter` instead of `VideoPreset`

* Use `mediaTrack.getSettings` when possible

* Safer dispose logic

* Safer `PCTransport`

Update transport.dart

* Synchronized events for `SignalClient`

* Use logger instead of print

* Make example compile for iOS

* First screen share implementation

* Make example work with screen share

* Example improvement

* Code optimization

* Don't depend on web_socket_channel

* Fix: Unpublish track bug

* Show participant mute state & identity

* Update protos

* Remote mute/unmute

* iOS Background mode

* Separate `createCameraTrack` and `createScreenTrack`

* Clean up

* PB fix

* format

* Fix analyzer warning

* Android clean up

* Update README.md

* Clean up
This commit is contained in:
Hiroshi Horie
2021-09-11 03:14:14 +09:00
committed by GitHub
parent 8e66e324f6
commit 50f56c5e1e
69 changed files with 2656 additions and 2354 deletions
+2
View File
@@ -37,6 +37,8 @@ linter:
# Additional recommended rules
#
prefer_single_quotes: true
unnecessary_brace_in_string_interps: false
unawaited_futures: true
#
# Turn off avoid_print for example projects
+5 -5
View File
@@ -32,14 +32,14 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
// compileOptions {
// sourceCompatibility JavaVersion.VERSION_1_8
// targetCompatibility JavaVersion.VERSION_1_8
// }
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.livekit.flutter_example"
applicationId "io.livekit.example"
minSdkVersion 21
targetSdkVersion 30
versionCode flutterVersionCode.toInteger()
@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.livekit_example">
package="io.livekit.example">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
@@ -1,51 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.livekit_example">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
package="io.livekit.example">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:label="LiveKit Example"
android:icon="@mipmap/ic_launcher">
<activity
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</application>
</manifest>
@@ -1,4 +1,4 @@
package com.example.livekit_example
package io.livekit.example
import io.flutter.embedding.android.FlutterActivity
@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.livekit_example">
package="io.livekit.example">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
+1 -1
View File
@@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.android.tools.build:gradle:7.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
+10 -1
View File
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '9.0'
platform :ios, '12.1'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -37,5 +37,14 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
#
# Fix deployment target issue
# https://stackoverflow.com/questions/63973136/the-ios-deployment-target-iphoneos-deployment-target-is-set-to-8-0-in-flutter
#
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.1'
end
end
end
+8 -2
View File
@@ -8,11 +8,14 @@ PODS:
- Libyuv (1703)
- path_provider (0.0.1):
- Flutter
- shared_preferences (0.0.1):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- path_provider (from `.symlinks/plugins/path_provider/ios`)
- shared_preferences (from `.symlinks/plugins/shared_preferences/ios`)
SPEC REPOS:
trunk:
@@ -26,6 +29,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_webrtc/ios"
path_provider:
:path: ".symlinks/plugins/path_provider/ios"
shared_preferences:
:path: ".symlinks/plugins/shared_preferences/ios"
SPEC CHECKSUMS:
Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c
@@ -33,7 +38,8 @@ SPEC CHECKSUMS:
GoogleWebRTC: b39a78c4f5cc6b0323415b9233db03a2faa7b0f0
Libyuv: 5f79ced0ee66e60a612ca97de1e6ccacd187a437
path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
PODFILE CHECKSUM: 6055d9653e1011c0b3b671abb92cdea979357e63
COCOAPODS: 1.10.2
COCOAPODS: 1.10.1
+6 -6
View File
@@ -357,14 +357,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 9Z5V633C2T;
DEVELOPMENT_TEAM = J48VV6BZV9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.livekitExample;
PRODUCT_BUNDLE_IDENTIFIER = example.livekit.io;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -489,14 +489,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 9Z5V633C2T;
DEVELOPMENT_TEAM = J48VV6BZV9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.livekitExample;
PRODUCT_BUNDLE_IDENTIFIER = example.livekit.io;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -515,14 +515,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 9Z5V633C2T;
DEVELOPMENT_TEAM = J48VV6BZV9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.livekitExample;
PRODUCT_BUNDLE_IDENTIFIER = example.livekit.io;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+8 -4
View File
@@ -22,6 +22,14 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) will use camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) will use microphone</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -41,9 +49,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) will use camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) will use microphone</string>
</dict>
</plist>
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
extension LKExampleExt on BuildContext {
//
Future<void> showErrorDialog(dynamic exception) => showDialog<void>(
context: this,
builder: (ctx) => AlertDialog(
title: const Text('Error'),
content: Text(exception.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
)
],
),
);
}
+11 -102
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:livekit_example/theme.dart';
import 'package:logging/logging.dart';
import 'package:livekit_client/livekit_client.dart';
import 'room.dart';
import 'pages/connect.dart';
void main() {
// configure logs for debugging
@@ -10,113 +11,21 @@ void main() {
print('${record.level.name}: ${record.time}: ${record.message}');
});
runApp(const MyApp());
WidgetsFlutterBinding.ensureInitialized();
runApp(const LiveKitExampleApp());
}
class MyApp extends StatelessWidget {
class LiveKitExampleApp extends StatelessWidget {
//
const MyApp({
const LiveKitExampleApp({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) => MaterialApp(
title: 'LiveKit Demo',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
),
home: const PreConnectWidget(
url: '<livekit_host>',
token: '<access_token>',
),
);
}
class PreConnectWidget extends StatefulWidget {
//
final String url;
final String token;
const PreConnectWidget({
required this.url,
required this.token,
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _PreConnectWidgetState();
}
class _PreConnectWidgetState extends State<PreConnectWidget> {
//
final _urlCtrl = TextEditingController();
final _tokenCtrl = TextEditingController();
@override
void initState() {
super.initState();
_urlCtrl.text = widget.url;
_tokenCtrl.text = widget.token;
}
@override
void dispose() {
_urlCtrl.dispose();
_tokenCtrl.dispose();
super.dispose();
}
void _connect(BuildContext context) async {
try {
print('Connecting with url: ${_urlCtrl.text}, token: ${_tokenCtrl.text}...');
final room = await LiveKitClient.connect(
_urlCtrl.text,
_tokenCtrl.text,
);
Navigator.push<void>(
context,
MaterialPageRoute(builder: (context) {
return RoomWidget(room);
}),
);
} catch (e) {
print('could not connect $e');
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Connect to LiveKit'),
),
body: Center(
child: Container(
// width: 250,
alignment: Alignment.center,
margin: const EdgeInsets.all(10),
child: Column(
children: [
TextField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'URL',
),
),
TextField(
controller: _tokenCtrl,
decoration: const InputDecoration(
labelText: 'Token',
),
),
TextButton(
onPressed: () => _connect(context),
child: const Text('Connect'),
),
],
),
),
),
title: 'LiveKit Flutter Example',
theme: LiveKitTheme().buildThemeData(context),
home: const ConnectPage(),
);
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../exts.dart';
import 'room.dart';
class ConnectPage extends StatefulWidget {
//
const ConnectPage({
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _ConnectPageState();
}
class _ConnectPageState extends State<ConnectPage> {
//
static const _storeKeyUri = 'uri';
static const _storeKeyToken = 'token';
static const _storeKeySimulcast = 'simulcast';
final _uriCtrl = TextEditingController();
final _tokenCtrl = TextEditingController();
bool _simulcast = false;
bool _busy = false;
@override
void initState() {
super.initState();
_readPrefs();
}
@override
void dispose() {
_uriCtrl.dispose();
_tokenCtrl.dispose();
super.dispose();
}
Future<void> _readPrefs() async {
final prefs = await SharedPreferences.getInstance();
_uriCtrl.text = prefs.getString(_storeKeyUri) ?? '';
_tokenCtrl.text = prefs.getString(_storeKeyToken) ?? '';
setState(() {
_simulcast = prefs.getBool(_storeKeySimulcast) ?? false;
});
}
Future<void> _writePrefs() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_storeKeyUri, _uriCtrl.text);
await prefs.setString(_storeKeyToken, _tokenCtrl.text);
await prefs.setBool(_storeKeySimulcast, _simulcast);
}
Future<void> _connect(BuildContext ctx) async {
//
try {
setState(() {
_busy = true;
});
print('Connecting with url: ${_uriCtrl.text}, token: ${_tokenCtrl.text}...');
final room = await LiveKitClient.connect(
_uriCtrl.text,
_tokenCtrl.text,
options: ConnectOptions(
defaultPublishOptions: TrackPublishOptions(
simulcast: _simulcast,
),
),
);
// Save for next time
await _writePrefs();
await Navigator.push<void>(
ctx,
MaterialPageRoute(builder: (_) => RoomPage(room)),
);
} catch (error) {
print('could not connect $error');
await ctx.showErrorDialog(error);
} finally {
setState(() {
_busy = false;
});
}
}
void _setSimulcast(bool? value) async {
if (value == null || _simulcast == value) return;
setState(() {
_simulcast = value;
});
// await _writePrefs();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Connect to LiveKit'),
),
body: Center(
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 20,
horizontal: 20,
),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Theme.of(context).colorScheme.secondary),
),
constraints: const BoxConstraints(
maxWidth: 320,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _uriCtrl,
decoration: const InputDecoration(labelText: 'URL'),
),
TextField(
controller: _tokenCtrl,
decoration: const InputDecoration(labelText: 'Token'),
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: CheckboxListTile(
controlAffinity: ListTileControlAffinity.leading,
onChanged: (value) => _setSimulcast(value),
title: const Text('Use Simulcast'),
value: _simulcast,
),
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: ElevatedButton(
onPressed: _busy ? null : () => _connect(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_busy)
const Padding(
padding: EdgeInsets.only(right: 10),
child: SizedBox(
height: 15,
width: 15,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
),
const Text('Connect'),
],
),
),
),
],
),
),
),
);
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart';
import '../widgets/controls.dart';
import '../widgets/participant.dart';
class RoomPage extends StatefulWidget {
//
final Room room;
const RoomPage(
this.room, {
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _RoomPageState();
}
}
class _RoomPageState extends State<RoomPage> with RoomDelegate {
// BuildContext? _lastContext;
//
List<Participant> participants = [];
@override
void initState() {
super.initState();
widget.room.delegate = this;
widget.room.addListener(_onChange);
_onConnected();
}
@override
void dispose() {
widget.room.delegate = null;
widget.room.removeListener(_onChange);
super.dispose();
}
void _onConnected() async {
// video will fail when running in ios simulator
try {
final localVideo = await LocalVideoTrack.createCameraTrack(); // Defaults to camera
await widget.room.localParticipant.publishVideoTrack(
localVideo,
// options: TrackPublishOptions(
// // simulcast: true,
// videoEncoding: VideoParameters.presetQVGA169.encoding,
// ),
);
} catch (e) {
print('could not publish video: $e');
}
final localAudio = await LocalAudioTrack.create();
await widget.room.localParticipant.publishAudioTrack(localAudio);
sortParticipants();
}
void _onChange() {
sortParticipants();
}
void sortParticipants() {
List<Participant> participants = [];
participants.addAll(widget.room.participants.values);
// sort speakers for the grid
participants.sort((a, b) {
// loudest speaker first
if (a.isSpeaking && b.isSpeaking) {
if (a.audioLevel > b.audioLevel) {
return -1;
} else {
return 1;
}
}
// last spoken at
final aSpokeAt = a.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// video on
if (a.hasVideo != b.hasVideo) {
return a.hasVideo ? -1 : 1;
}
// joinedAt
return a.joinedAt.millisecondsSinceEpoch - b.joinedAt.millisecondsSinceEpoch;
});
if (participants.length > 1) {
participants.insert(1, widget.room.localParticipant);
} else {
participants.add(widget.room.localParticipant);
}
setState(() {
this.participants = participants;
});
}
@override
void onDisconnected() {
// final context = _lastContext;
print('disconnected: $context');
// if (context != null) {
Navigator.pop(context);
// }
}
@override
Widget build(BuildContext context) => Scaffold(
// with a provider, any child/descendent widget can be updated if they
// are a Consumer of Room.
body: ChangeNotifierProvider.value(
value: widget.room,
child: Column(
children: [
Expanded(
child: participants.isNotEmpty
? ParticipantWidget(participants.first)
: Container()),
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, participants.length - 1),
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,
child: ControlsWidget(widget.room),
),
],
),
),
);
}
-229
View File
@@ -1,229 +0,0 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:livekit_example/src/controls.dart';
import 'package:provider/provider.dart';
class RoomWidget extends StatefulWidget {
//
final Room room;
const RoomWidget(
this.room, {
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _RoomState();
}
}
class _RoomState extends State<RoomWidget> with RoomDelegate {
BuildContext? _lastContext;
List<Participant> participants = [];
@override
void initState() {
super.initState();
widget.room.delegate = this;
widget.room.addListener(_onChange);
_onConnected();
}
@override
void dispose() {
widget.room.delegate = null;
super.dispose();
}
void _onConnected() async {
// video will fail when running in ios simulator
try {
final localVideo = await LocalVideoTrack.createCameraTrack();
await widget.room.localParticipant.publishVideoTrack(localVideo);
} catch (e) {
print('could not publish video: $e');
}
final localAudio = await LocalAudioTrack.createTrack();
await widget.room.localParticipant.publishAudioTrack(localAudio);
sortParticipants();
}
void _onChange() {
sortParticipants();
}
void sortParticipants() {
List<Participant> participants = [];
participants.addAll(widget.room.participants.values);
// sort speakers for the grid
participants.sort((a, b) {
// loudest speaker first
if (a.isSpeaking && b.isSpeaking) {
if (a.audioLevel > b.audioLevel) {
return -1;
} else {
return 1;
}
}
// last spoken at
final aSpokeAt = a.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
final bSpokeAt = b.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
if (aSpokeAt != bSpokeAt) {
return aSpokeAt > bSpokeAt ? -1 : 1;
}
// video on
if (a.hasVideo != b.hasVideo) {
return a.hasVideo ? -1 : 1;
}
// joinedAt
return a.joinedAt.millisecondsSinceEpoch - b.joinedAt.millisecondsSinceEpoch;
});
if (participants.length > 1) {
participants.insert(1, widget.room.localParticipant);
} else {
participants.add(widget.room.localParticipant);
}
setState(() {
this.participants = participants;
});
}
@override
void onDisconnected() {
final context = _lastContext;
print('disconnected: $context');
if (context != null) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
_lastContext = context;
final mainWidgets = <Widget>[];
final participants = this.participants;
if (participants.isNotEmpty) {
mainWidgets.add(Expanded(child: VideoView(participants.first)));
} else {
mainWidgets.add(Expanded(child: Container()));
}
if (participants.length > 1) {
final videoList = ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: participants.length - 1,
itemBuilder: (BuildContext context, int index) {
return Container(
width: 100,
height: 60,
padding: const EdgeInsets.all(2),
child: VideoView(participants[index + 1], quality: VideoQuality.LOW),
);
},
);
mainWidgets.add(SizedBox(
height: 60,
child: videoList,
));
}
mainWidgets.add(Controls(widget.room));
return MaterialApp(
title: 'LiveKit Video Room',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
),
home: Scaffold(
// with a provider, any child/descendent widget can be updated if they
// are a Consumer of Room.
body: ChangeNotifierProvider.value(
value: widget.room,
child: Column(
children: mainWidgets,
))));
}
}
// displays a participant in view
class VideoView extends StatefulWidget {
//
final Participant participant;
final VideoQuality quality;
const VideoView(
this.participant, {
this.quality = VideoQuality.MEDIUM,
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _VideoViewState();
}
}
class _VideoViewState extends State<VideoView> with ParticipantDelegate {
TrackPublication? videoPub;
@override
void initState() {
super.initState();
widget.participant.addListener(_onParticipantChanged);
_onParticipantChanged();
}
@override
void dispose() {
widget.participant.removeListener(_onParticipantChanged);
super.dispose();
}
@override
void didUpdateWidget(covariant VideoView oldWidget) {
oldWidget.participant.removeListener(_onParticipantChanged);
widget.participant.addListener(_onParticipantChanged);
_onParticipantChanged();
super.didUpdateWidget(oldWidget);
}
// register for change so Flutter will re-build the widget upon change
void _onParticipantChanged() {
final subscribedVideos = widget.participant.videoTracks.values.where((pub) {
return pub.kind == TrackType.VIDEO && !pub.isScreenShare && pub.subscribed;
});
setState(() {
if (subscribedVideos.isNotEmpty) {
final videoPub = subscribedVideos.first;
if (videoPub is RemoteTrackPublication) {
videoPub.videoQuality = widget.quality;
}
// when muted, show placeholder
if (!videoPub.muted) {
this.videoPub = videoPub;
return;
}
}
videoPub = null;
});
}
@override
Widget build(BuildContext context) {
final videoPub = this.videoPub;
if (videoPub != null) {
return VideoTrackRenderer(videoPub.track as VideoTrack);
} else {
return Container(
color: Colors.grey,
);
}
}
}
-178
View File
@@ -1,178 +0,0 @@
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
class Controls extends StatefulWidget {
//
final Room room;
final LocalParticipant participant;
Controls(
this.room, {
Key? key,
}) : participant = room.localParticipant,
super(key: key);
@override
State<StatefulWidget> createState() {
return _ControlsState();
}
}
class _ControlsState extends State<Controls> {
CameraPosition position = CameraPosition.front;
@override
void initState() {
super.initState();
participant.addListener(_onChange);
}
@override
void dispose() {
participant.removeListener(_onChange);
super.dispose();
}
LocalParticipant get participant => widget.participant;
void _onChange() {
// trigger refresh
setState(() {});
}
void _muteAudio() {
if (participant.hasAudio) {
final audioPub = participant.audioTracks.values.first;
audioPub.muted = true;
}
}
Future<void> _unmuteAudio() async {
if (participant.hasAudio) {
final audioPub = participant.audioTracks.values.first;
audioPub.muted = false;
} else {
// publish audio track
final audioTrack = await LocalAudioTrack.createTrack();
await participant.publishAudioTrack(audioTrack);
}
}
void _muteVideo() {
if (participant.hasVideo) {
final videoPub = participant.videoTracks.values.first;
videoPub.muted = true;
}
}
void _unmuteVideo() async {
if (participant.hasVideo) {
final videoPub = participant.videoTracks.values.first;
videoPub.muted = false;
} else {
// publish audio track
final videoTrack = await LocalVideoTrack.createCameraTrack();
await participant.publishVideoTrack(videoTrack);
}
}
void _setCameraPosition(TrackPublication? pub, CameraPosition position) async {
if (this.position == position) {
return;
}
LocalVideoTrack? track;
if (pub?.track is LocalVideoTrack) {
track = pub!.track as LocalVideoTrack;
}
if (track == null) {
return;
}
try {
await track.restartTrack(LocalVideoTrackOptions(position: position));
} catch (e) {
print('could not restart track: $e');
return;
}
setState(() {
this.position = position;
});
}
void _exit() {
widget.room.disconnect();
}
@override
Widget build(BuildContext context) {
final buttons = <Widget>[];
// mute audio
if (participant.hasAudio && !participant.isMuted) {
buttons.add(
IconButton(
onPressed: _muteAudio,
icon: const Icon(Icons.mic_rounded),
),
);
} else {
buttons.add(
IconButton(
onPressed: _unmuteAudio,
icon: const Icon(Icons.mic_off_rounded),
),
);
}
// mute video
TrackPublication? videoPub;
if (participant.hasVideo) {
videoPub = participant.videoTracks.values.first;
}
final videoEnabled = videoPub != null && !videoPub.muted;
if (videoEnabled) {
buttons.add(IconButton(
onPressed: _muteVideo,
icon: const Icon(Icons.videocam_rounded),
));
} else {
buttons.add(IconButton(
onPressed: _unmuteVideo,
icon: const Icon(Icons.videocam_off_rounded),
));
}
if (position == CameraPosition.front) {
buttons.add(IconButton(
icon: const Icon(Icons.video_camera_front_rounded),
onPressed: videoEnabled
? () {
_setCameraPosition(videoPub, CameraPosition.back);
}
: null,
));
} else {
buttons.add(IconButton(
icon: const Icon(Icons.video_camera_back_rounded),
onPressed: videoEnabled
? () {
_setCameraPosition(videoPub, CameraPosition.front);
}
: null,
));
}
buttons.add(IconButton(
onPressed: _exit,
icon: const Icon(Icons.close_rounded),
));
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: buttons,
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
//
// Flutter has a color profile issue so colors will look different
// on Apple devices.
// https://github.com/flutter/flutter/issues/55092
// https://github.com/flutter/flutter/issues/39113
//
class LiveKitTheme {
//
final bgColor = Colors.black;
final textColor = Colors.white;
final cardColor = const Color(0xFF00163c);
final accentColor = const Color(0xFF2d6aef);
ThemeData buildThemeData(BuildContext ctx) => ThemeData(
backgroundColor: bgColor,
// accentColor: accentColor,
colorScheme: ColorScheme.fromSwatch(primarySwatch: Colors.blue),
appBarTheme: AppBarTheme(
backgroundColor: cardColor,
),
cardColor: cardColor,
scaffoldBackgroundColor: bgColor,
canvasColor: bgColor,
iconTheme: IconThemeData(
color: textColor,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
// backgroundColor: MaterialStateProperty.all<Color>(accentColor),
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) return accentColor.withOpacity(0.5);
return accentColor;
}),
),
),
checkboxTheme: CheckboxThemeData(
checkColor: MaterialStateProperty.all(Colors.white),
fillColor: MaterialStateProperty.all(accentColor),
),
dialogBackgroundColor: cardColor,
textTheme: GoogleFonts.latoTextTheme(
Theme.of(ctx).textTheme,
).apply(
displayColor: textColor,
bodyColor: textColor,
decorationColor: textColor,
),
hintColor: Colors.red,
inputDecorationTheme: InputDecorationTheme(
labelStyle: TextStyle(
color: textColor.withOpacity(.5),
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: textColor.withOpacity(0.1)),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: accentColor),
),
),
);
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart';
class ControlsWidget extends StatefulWidget {
//
final Room room;
final LocalParticipant participant;
ControlsWidget(
this.room, {
Key? key,
}) : participant = room.localParticipant,
super(key: key);
@override
State<StatefulWidget> createState() => _ControlsWidgetState();
}
class _ControlsWidgetState extends State<ControlsWidget> {
//
CameraPosition position = CameraPosition.front;
@override
void initState() {
super.initState();
participant.addListener(_onChange);
}
@override
void dispose() {
participant.removeListener(_onChange);
super.dispose();
}
LocalParticipant get participant => widget.participant;
void _onChange() {
// trigger refresh
setState(() {});
}
void _muteAudio() {
if (participant.hasAudio) {
final audioPub = participant.audioTracks.first;
audioPub.muted = true;
}
}
Future<void> _unmuteAudio() async {
if (participant.hasAudio) {
final audioPub = participant.audioTracks.first;
audioPub.muted = false;
} else {
// publish audio track
final audioTrack = await LocalAudioTrack.create();
await participant.publishAudioTrack(audioTrack);
}
}
void _muteVideo() {
if (participant.hasVideo) {
final videoPub = participant.videoTracks.first;
videoPub.muted = true;
}
}
void _unmuteVideo() async {
if (participant.hasVideo) {
print('Un-muting video');
final videoPub = participant.videoTracks.first;
videoPub.muted = false;
} else {
// publish audio track
final videoTrack = await LocalVideoTrack.createCameraTrack();
await participant.publishVideoTrack(videoTrack);
}
}
void _toggleCamera() async {
//
final track = participant.videoTracks.firstOrNull?.track as LocalVideoTrack?;
if (track == null) return;
try {
final newPosition = position.swap();
await track.setCameraPosition(newPosition);
setState(() {
position = newPosition;
});
} catch (error) {
print('could not restart track: $error');
return;
}
}
void _shareScreen() async {
//
final lp = widget.room.localParticipant;
for (final tracks in lp.videoTracks) {
await lp.unpublishTrack(tracks.track!);
}
try {
final screenTrack = await LocalVideoTrack.createScreenTrack(); // Defaults to camera
await widget.room.localParticipant.publishVideoTrack(
screenTrack,
);
} catch (e) {
print('could not publish video: $e');
}
}
void _exit() {
widget.room.disconnect();
}
@override
Widget build(BuildContext context) {
// mute audio
final canMute = participant.hasAudio && !participant.isMuted;
final videoPub = participant.videoTracks.firstOrNull;
final videoEnabled = videoPub != null && !videoPub.muted;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (canMute)
IconButton(
onPressed: _muteAudio,
icon: const Icon(EvaIcons.mic),
)
else
IconButton(
onPressed: _unmuteAudio,
icon: const Icon(EvaIcons.micOff),
),
if (videoEnabled)
IconButton(
onPressed: _muteVideo,
icon: const Icon(EvaIcons.video),
)
else
IconButton(
onPressed: _unmuteVideo,
icon: const Icon(EvaIcons.videoOff),
),
IconButton(
icon: Icon(position == CameraPosition.back ? EvaIcons.camera : EvaIcons.person),
onPressed: () => _toggleCamera(),
),
IconButton(
icon: const Icon(EvaIcons.monitor),
onPressed: () => _shareScreen(),
),
IconButton(
onPressed: _exit,
icon: const Icon(EvaIcons.closeCircle),
)
],
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
import 'package:flutter/material.dart';
import 'dart:math' as math;
class NoVideoWidget extends StatelessWidget {
//
const NoVideoWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
alignment: Alignment.center,
child: LayoutBuilder(
builder: (ctx, constraints) => Icon(
EvaIcons.videoOffOutline,
color: Theme.of(ctx).colorScheme.secondary,
size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3,
),
),
);
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'no_video.dart';
import 'participant_info.dart';
class ParticipantWidget extends StatefulWidget {
//
final Participant participant;
final VideoQuality quality;
const ParticipantWidget(
this.participant, {
this.quality = VideoQuality.MEDIUM,
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _ParticipantWidgetState();
}
class _ParticipantWidgetState extends State<ParticipantWidget> with ParticipantDelegate {
//
TrackPublication? videoPub;
TrackPublication? audioPub;
@override
void initState() {
super.initState();
widget.participant.addListener(_onParticipantChanged);
_onParticipantChanged();
}
@override
void dispose() {
widget.participant.removeListener(_onParticipantChanged);
super.dispose();
}
@override
void didUpdateWidget(covariant ParticipantWidget oldWidget) {
oldWidget.participant.removeListener(_onParticipantChanged);
widget.participant.addListener(_onParticipantChanged);
_onParticipantChanged();
super.didUpdateWidget(oldWidget);
}
// register for change so Flutter will re-build the widget upon change
void _onParticipantChanged() {
//
final firstAudio = widget.participant.audioTracks.firstWhereOrNull((pub) => pub.subscribed);
final firstVideo = widget.participant.videoTracks
.firstWhereOrNull((pub) => !pub.isScreenShare && pub.subscribed);
if (firstVideo is RemoteTrackPublication) {
firstVideo.videoQuality = widget.quality;
}
setState(() {
audioPub = !(firstAudio?.muted ?? true) ? firstAudio : null;
videoPub = !(firstVideo?.muted ?? true) ? firstVideo : null;
});
}
@override
Widget build(BuildContext ctx) => Container(
color: Theme.of(ctx).cardColor,
child: Stack(
children: [
// Video
if (videoPub != null)
VideoTrackRenderer(
videoPub!.track as VideoTrack,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
)
else
const NoVideoWidget(),
Align(
alignment: Alignment.bottomCenter,
child: ParticipantInfoWidget(
title: widget.participant.identity,
muted: audioPub == null,
),
),
],
),
);
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
import 'package:flutter/material.dart';
class ParticipantInfoWidget extends StatelessWidget {
//
final String? title;
final bool muted;
const ParticipantInfoWidget({
this.title,
this.muted = true,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
color: Colors.black.withOpacity(0.3),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (title != null)
Flexible(
child: Text(
title!,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
!muted ? EvaIcons.mic : EvaIcons.micOff,
color: !muted ? Colors.white : Colors.red,
size: 16,
),
),
],
),
);
}
+78 -17
View File
@@ -7,7 +7,7 @@ packages:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.6.1"
version: "2.8.1"
boolean_selector:
dependency: transitive
description:
@@ -28,7 +28,7 @@ packages:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.3.1"
clock:
dependency: transitive
description:
@@ -50,13 +50,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
cupertino_icons:
eva_icons_flutter:
dependency: "direct main"
description:
name: cupertino_icons
name: eva_icons_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
version: "3.0.0"
fake_async:
dependency: transitive
description:
@@ -102,13 +102,25 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: transitive
description:
name: flutter_webrtc
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.6"
version: "0.6.7"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
http:
dependency: transitive
description:
@@ -123,6 +135,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
js:
dependency: transitive
description:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
lints:
dependency: transitive
description:
@@ -157,7 +176,7 @@ packages:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
version: "1.7.0"
nested:
dependency: transitive
description:
@@ -178,7 +197,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
version: "2.0.3"
path_provider_linux:
dependency: transitive
description:
@@ -256,6 +275,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
sky_engine:
dependency: transitive
description: flutter
@@ -289,6 +350,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
term_glyph:
dependency: transitive
description:
@@ -302,7 +370,7 @@ packages:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
version: "0.4.2"
tuple:
dependency: transitive
description:
@@ -331,20 +399,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.7"
version: "2.2.8"
xdg_directories:
dependency: transitive
description:
+7 -3
View File
@@ -10,11 +10,15 @@ environment:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
livekit_client:
path: ../
provider: ^5.0.0
logging: ^1.0.1
google_fonts: ^2.1.0
eva_icons_flutter: ^3.0.0
shared_preferences: ^2.0.7
livekit_client:
path: ../
dev_dependencies:
flutter_test:
+1 -1
View File
@@ -13,7 +13,7 @@ import 'package:livekit_example/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(const LiveKitExampleApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);