* feat: audio output select for web. * chore: Add comments for VideoTrackRenderer. * update.
316 lines
10 KiB
Dart
316 lines
10 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;
|
|
List<MediaDevice>? _audioOutputs;
|
|
MediaDevice? _selectedAudioDevice;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.participant.addListener(_onParticipantChanged);
|
|
_onParticipantChanged();
|
|
Hardware.instance.audioOutputs().then((value) {
|
|
setState(() {
|
|
_audioOutputs = value;
|
|
_selectedAudioDevice = _audioOutputs?.firstOrNull;
|
|
});
|
|
});
|
|
}
|
|
|
|
@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(() {});
|
|
|
|
void _onSelectAudioOutput(MediaDevice device) {
|
|
var audioTrack = firstAudioPublication?.track as RemoteAudioTrack;
|
|
audioTrack.setAudioOutput(device.deviceId);
|
|
setState(() {
|
|
_selectedAudioDevice = device;
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
),
|
|
if (lkPlatformIs(PlatformType.web))
|
|
RemoteTrackAudioOutputSelectMenuWidget(
|
|
audioOutputs: _audioOutputs ?? [],
|
|
selected: _selectedAudioDevice,
|
|
onSelected: _onSelectAudioOutput,
|
|
icon: EvaIcons.speaker,
|
|
),
|
|
],
|
|
),
|
|
];
|
|
}
|
|
|
|
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(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class RemoteTrackAudioOutputSelectMenuWidget extends StatelessWidget {
|
|
final IconData icon;
|
|
final List<MediaDevice> audioOutputs;
|
|
final MediaDevice? selected;
|
|
final Function(MediaDevice) onSelected;
|
|
const RemoteTrackAudioOutputSelectMenuWidget({
|
|
required this.audioOutputs,
|
|
required this.onSelected,
|
|
required this.selected,
|
|
required this.icon,
|
|
Key? key,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Material(
|
|
color: Colors.black.withOpacity(0.3),
|
|
child: PopupMenuButton<Function>(
|
|
tooltip: 'Select AudioOutput',
|
|
icon: Icon(icon),
|
|
onSelected: (value) => value(),
|
|
itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[
|
|
...audioOutputs
|
|
.map((e) => PopupMenuItem(
|
|
child: ListTile(
|
|
trailing: (e.deviceId == selected?.deviceId)
|
|
? const Icon(Icons.check, color: Colors.white)
|
|
: null,
|
|
title: Text(e.label),
|
|
),
|
|
value: () => onSelected(e),
|
|
))
|
|
.toList(),
|
|
],
|
|
),
|
|
);
|
|
}
|