diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index 5c2256c..2da90b1 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -317,43 +317,45 @@ class _ControlsWidgetState extends State { icon: const Icon(EvaIcons.micOff), tooltip: 'un-mute audio', ), - PopupMenuButton( - icon: const Icon(Icons.volume_up), - itemBuilder: (BuildContext context) { - return [ - const PopupMenuItem( - value: null, - child: ListTile( - leading: Icon( - EvaIcons.speaker, - color: Colors.white, - ), - title: Text('Select Audio Output'), - ), - ), - if (_audioOutputs != null) - ..._audioOutputs!.map((device) { - return PopupMenuItem( - value: device, - child: ListTile( - leading: (device.deviceId == - Hardware.instance.selectedAudioOutput?.deviceId) - ? const Icon( - EvaIcons.checkmarkSquare, - color: Colors.white, - ) - : const Icon( - EvaIcons.square, - color: Colors.white, - ), - title: Text(device.label), + if (!lkPlatformIs(PlatformType.web)) + PopupMenuButton( + icon: const Icon(Icons.volume_up), + itemBuilder: (BuildContext context) { + return [ + const PopupMenuItem( + value: null, + child: ListTile( + leading: Icon( + EvaIcons.speaker, + color: Colors.white, ), - onTap: () => _selectAudioOutput(device), - ); - }).toList() - ]; - }, - ), + title: Text('Select Audio Output'), + ), + ), + if (_audioOutputs != null) + ..._audioOutputs!.map((device) { + return PopupMenuItem( + value: device, + child: ListTile( + leading: (device.deviceId == + Hardware + .instance.selectedAudioOutput?.deviceId) + ? const Icon( + EvaIcons.checkmarkSquare, + color: Colors.white, + ) + : const Icon( + EvaIcons.square, + color: Colors.white, + ), + title: Text(device.label), + ), + onTap: () => _selectAudioOutput(device), + ); + }).toList() + ]; + }, + ), if (participant.isCameraEnabled()) PopupMenuButton( icon: const Icon(EvaIcons.video), diff --git a/example/lib/widgets/participant.dart b/example/lib/widgets/participant.dart index 070f6e5..29dda3c 100644 --- a/example/lib/widgets/participant.dart +++ b/example/lib/widgets/participant.dart @@ -82,12 +82,20 @@ abstract class _ParticipantWidgetState VideoTrack? get activeVideoTrack; TrackPublication? get videoPublication; TrackPublication? get firstAudioPublication; + List? _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 @@ -108,6 +116,14 @@ abstract class _ParticipantWidgetState // 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 extraWidgets(bool isScreenShare) => []; @@ -130,10 +146,8 @@ abstract class _ParticipantWidgetState InkWell( onTap: () => setState(() => _visible = !_visible), child: activeVideoTrack != null && !activeVideoTrack!.muted - ? VideoTrackRenderer( - activeVideoTrack!, - fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, - ) + ? VideoTrackRenderer(activeVideoTrack!, + fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : const NoVideoWidget(), ), @@ -144,7 +158,9 @@ abstract class _ParticipantWidgetState crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - ...extraWidgets(widget.isScreenShare), + ...extraWidgets( + widget.isScreenShare, + ), ParticipantInfoWidget( title: widget.participant.name.isNotEmpty ? '${widget.participant.name} (${widget.participant.identity})' @@ -211,6 +227,13 @@ class _RemoteParticipantWidgetState pub: firstAudioPublication!, icon: EvaIcons.volumeUp, ), + if (lkPlatformIs(PlatformType.web)) + RemoteTrackAudioOutputSelectMenuWidget( + audioOutputs: _audioOutputs ?? [], + selected: _selectedAudioDevice, + onSelected: _onSelectAudioOutput, + icon: EvaIcons.speaker, + ), ], ), ]; @@ -253,3 +276,40 @@ class RemoteTrackPublicationMenuWidget extends StatelessWidget { ), ); } + +class RemoteTrackAudioOutputSelectMenuWidget extends StatelessWidget { + final IconData icon; + final List 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( + tooltip: 'Select AudioOutput', + icon: Icon(icon), + onSelected: (value) => value(), + itemBuilder: (BuildContext context) => >[ + ...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(), + ], + ), + ); +} diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 6d01f3a..e541da6 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -17,6 +17,7 @@ export 'src/proto/livekit_models.pb.dart' show TrackType, VideoQuality; export 'src/publication/local.dart'; export 'src/publication/remote.dart'; export 'src/publication/track_publication.dart'; +export 'src/support/platform.dart'; export 'src/track/local/audio.dart'; export 'src/track/local/local.dart'; export 'src/track/local/video.dart'; diff --git a/lib/src/support/platform.dart b/lib/src/support/platform.dart index 3e7ba4b..f216483 100644 --- a/lib/src/support/platform.dart +++ b/lib/src/support/platform.dart @@ -1,7 +1,5 @@ import 'dart:io'; -import 'package:meta/meta.dart'; - import 'platform/io.dart' if (dart.library.html) 'platform/web.dart'; // Returns the current platform which works for both web and devices. @@ -9,7 +7,6 @@ PlatformType lkPlatform() => lkPlatformImplementation(); bool lkPlatformIs(PlatformType type) => lkPlatform() == type; -@internal bool lkPlatformIsTest() => Platform.environment.containsKey('FLUTTER_TEST'); enum PlatformType { diff --git a/lib/src/track/remote/audio.dart b/lib/src/track/remote/audio.dart index 95376b3..d522a8e 100644 --- a/lib/src/track/remote/audio.dart +++ b/lib/src/track/remote/audio.dart @@ -43,4 +43,8 @@ class RemoteAudioTrack extends RemoteTrack } return didStop; } + + Future setAudioOutput(String deviceId) async { + audio.setAudioOutput(getCid(), deviceId); + } } diff --git a/lib/src/track/web/_audio_api.dart b/lib/src/track/web/_audio_api.dart index 599e4ae..961726a 100644 --- a/lib/src/track/web/_audio_api.dart +++ b/lib/src/track/web/_audio_api.dart @@ -7,3 +7,7 @@ void startAudio(String id, rtc.MediaStreamTrack stream) { void stopAudio(String id) { // do nothing } + +void setAudioOutput(String id, String deviceId) { + // do nothing +} diff --git a/lib/src/track/web/_audio_html.dart b/lib/src/track/web/_audio_html.dart index 2a7f1be..000e230 100644 --- a/lib/src/track/web/_audio_html.dart +++ b/lib/src/track/web/_audio_html.dart @@ -51,3 +51,10 @@ html.DivElement findOrCreateAudioContainer() { html.document.body?.append(div); return div as html.DivElement; } + +void setAudioOutput(String id, String deviceId) { + final audioElement = html.document.getElementById(audioPrefix + id); + if (audioElement is html.AudioElement) { + audioElement.setSinkId(deviceId); + } +}