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 2653 additions and 2351 deletions
+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,
),
),
],
),
);
}