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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user