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