db2e25d424
* fix: Force audio settings to be consistent. * chore: applyAudioSettings for onUnpublish. * revert audio output select for web. * add RoomHardwareManagementMethods to Room. * update. * fix analyze. * update. * update. * chore: Reset sinkId for web when remoteTrack replays. * rename setAudioOutput to setSinkId for flutter web. * chore: Make sure AudioElement has `setSinkId` method.
256 lines
8.1 KiB
Dart
256 lines
8.1 KiB
Dart
import 'package:collection/collection.dart';
|
|
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
import 'package:livekit_client/livekit_client.dart';
|
|
import 'package:livekit_example/theme.dart';
|
|
|
|
import 'no_video.dart';
|
|
import 'participant_info.dart';
|
|
|
|
abstract class ParticipantWidget extends StatefulWidget {
|
|
// Convenience method to return relevant widget for participant
|
|
static ParticipantWidget widgetFor(ParticipantTrack participantTrack) {
|
|
if (participantTrack.participant is LocalParticipant) {
|
|
return LocalParticipantWidget(
|
|
participantTrack.participant as LocalParticipant,
|
|
participantTrack.videoTrack,
|
|
participantTrack.isScreenShare);
|
|
} else if (participantTrack.participant is RemoteParticipant) {
|
|
return RemoteParticipantWidget(
|
|
participantTrack.participant as RemoteParticipant,
|
|
participantTrack.videoTrack,
|
|
participantTrack.isScreenShare);
|
|
}
|
|
throw UnimplementedError('Unknown participant type');
|
|
}
|
|
|
|
// Must be implemented by child class
|
|
abstract final Participant participant;
|
|
abstract final VideoTrack? videoTrack;
|
|
abstract final bool isScreenShare;
|
|
final VideoQuality quality;
|
|
|
|
const ParticipantWidget({
|
|
this.quality = VideoQuality.MEDIUM,
|
|
Key? key,
|
|
}) : super(key: key);
|
|
}
|
|
|
|
class LocalParticipantWidget extends ParticipantWidget {
|
|
@override
|
|
final LocalParticipant participant;
|
|
@override
|
|
final VideoTrack? videoTrack;
|
|
@override
|
|
final bool isScreenShare;
|
|
|
|
const LocalParticipantWidget(
|
|
this.participant,
|
|
this.videoTrack,
|
|
this.isScreenShare, {
|
|
Key? key,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _LocalParticipantWidgetState();
|
|
}
|
|
|
|
class RemoteParticipantWidget extends ParticipantWidget {
|
|
@override
|
|
final RemoteParticipant participant;
|
|
@override
|
|
final VideoTrack? videoTrack;
|
|
@override
|
|
final bool isScreenShare;
|
|
|
|
const RemoteParticipantWidget(
|
|
this.participant,
|
|
this.videoTrack,
|
|
this.isScreenShare, {
|
|
Key? key,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
|
|
}
|
|
|
|
abstract class _ParticipantWidgetState<T extends ParticipantWidget>
|
|
extends State<T> {
|
|
//
|
|
bool _visible = true;
|
|
VideoTrack? get activeVideoTrack;
|
|
TrackPublication? get videoPublication;
|
|
TrackPublication? get firstAudioPublication;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.participant.addListener(_onParticipantChanged);
|
|
_onParticipantChanged();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.participant.removeListener(_onParticipantChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant T oldWidget) {
|
|
oldWidget.participant.removeListener(_onParticipantChanged);
|
|
widget.participant.addListener(_onParticipantChanged);
|
|
_onParticipantChanged();
|
|
super.didUpdateWidget(oldWidget);
|
|
}
|
|
|
|
// Notify Flutter that UI re-build is required, but we don't set anything here
|
|
// since the updated values are computed properties.
|
|
void _onParticipantChanged() => setState(() {});
|
|
|
|
// Widgets to show above the info bar
|
|
List<Widget> extraWidgets(bool isScreenShare) => [];
|
|
|
|
@override
|
|
Widget build(BuildContext ctx) => Container(
|
|
foregroundDecoration: BoxDecoration(
|
|
border: widget.participant.isSpeaking && !widget.isScreenShare
|
|
? Border.all(
|
|
width: 5,
|
|
color: LKColors.lkBlue,
|
|
)
|
|
: null,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(ctx).cardColor,
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// Video
|
|
InkWell(
|
|
onTap: () => setState(() => _visible = !_visible),
|
|
child: activeVideoTrack != null && !activeVideoTrack!.muted
|
|
? VideoTrackRenderer(
|
|
activeVideoTrack!,
|
|
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
|
)
|
|
: const NoVideoWidget(),
|
|
),
|
|
|
|
// Bottom bar
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
...extraWidgets(widget.isScreenShare),
|
|
ParticipantInfoWidget(
|
|
title: widget.participant.name.isNotEmpty
|
|
? '${widget.participant.name} (${widget.participant.identity})'
|
|
: widget.participant.identity,
|
|
audioAvailable: firstAudioPublication?.muted == false &&
|
|
firstAudioPublication?.subscribed == true,
|
|
connectionQuality: widget.participant.connectionQuality,
|
|
isScreenShare: widget.isScreenShare,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _LocalParticipantWidgetState
|
|
extends _ParticipantWidgetState<LocalParticipantWidget> {
|
|
@override
|
|
LocalTrackPublication<LocalVideoTrack>? get videoPublication =>
|
|
widget.participant.videoTracks
|
|
.where((element) => element.sid == widget.videoTrack?.sid)
|
|
.firstOrNull;
|
|
|
|
@override
|
|
LocalTrackPublication<LocalAudioTrack>? get firstAudioPublication =>
|
|
widget.participant.audioTracks.firstOrNull;
|
|
|
|
@override
|
|
VideoTrack? get activeVideoTrack => widget.videoTrack;
|
|
}
|
|
|
|
class _RemoteParticipantWidgetState
|
|
extends _ParticipantWidgetState<RemoteParticipantWidget> {
|
|
@override
|
|
RemoteTrackPublication<RemoteVideoTrack>? get videoPublication =>
|
|
widget.participant.videoTracks
|
|
.where((element) => element.sid == widget.videoTrack?.sid)
|
|
.firstOrNull;
|
|
|
|
@override
|
|
RemoteTrackPublication<RemoteAudioTrack>? get firstAudioPublication =>
|
|
widget.participant.audioTracks.firstOrNull;
|
|
|
|
@override
|
|
VideoTrack? get activeVideoTrack => widget.videoTrack;
|
|
|
|
@override
|
|
List<Widget> extraWidgets(bool isScreenShare) => [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
// Menu for RemoteTrackPublication<RemoteVideoTrack>
|
|
if (videoPublication != null)
|
|
RemoteTrackPublicationMenuWidget(
|
|
pub: videoPublication!,
|
|
icon: isScreenShare ? EvaIcons.monitor : EvaIcons.video,
|
|
),
|
|
// Menu for RemoteTrackPublication<RemoteAudioTrack>
|
|
if (firstAudioPublication != null && !isScreenShare)
|
|
RemoteTrackPublicationMenuWidget(
|
|
pub: firstAudioPublication!,
|
|
icon: EvaIcons.volumeUp,
|
|
),
|
|
],
|
|
),
|
|
];
|
|
}
|
|
|
|
class RemoteTrackPublicationMenuWidget extends StatelessWidget {
|
|
final IconData icon;
|
|
final RemoteTrackPublication pub;
|
|
const RemoteTrackPublicationMenuWidget({
|
|
required this.pub,
|
|
required this.icon,
|
|
Key? key,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Material(
|
|
color: Colors.black.withOpacity(0.3),
|
|
child: PopupMenuButton<Function>(
|
|
tooltip: 'Subscribe menu',
|
|
icon: Icon(icon,
|
|
color: {
|
|
TrackSubscriptionState.notAllowed: Colors.red,
|
|
TrackSubscriptionState.unsubscribed: Colors.grey,
|
|
TrackSubscriptionState.subscribed: Colors.green,
|
|
}[pub.subscriptionState]),
|
|
onSelected: (value) => value(),
|
|
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
|
|
// Subscribe/Unsubscribe
|
|
if (pub.subscribed == false)
|
|
PopupMenuItem(
|
|
child: const Text('Subscribe'),
|
|
value: () => pub.subscribe(),
|
|
)
|
|
else if (pub.subscribed == true)
|
|
PopupMenuItem(
|
|
child: const Text('Un-subscribe'),
|
|
value: () => pub.unsubscribe(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|