diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6854b..6274983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 1.1.1 + +* Add hardware api for camera and audio input/output selection. + ## 1.1.0-hotfix * Align the version in .podspec with the package version (fix compilation errors under ios/mac). diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7582ac8..8a5162a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,7 +2,7 @@ PODS: - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) - - flutter_webrtc (0.9.2): + - flutter_webrtc (0.9.4): - Flutter - WebRTC-SDK (= 104.5112.02) - livekit_client (1.1.0): @@ -43,7 +43,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - flutter_webrtc: af1337911865d904b45c5fc95605115af4d0cdc8 + flutter_webrtc: aa130dfe1eca6625c2e2e51ce830abb495bdb06e livekit_client: f0343eef54a12a4b831d3ec1085760f13eca9ae3 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index b19349a..c301a28 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -29,20 +30,41 @@ class _ControlsWidgetState extends State { // CameraPosition position = CameraPosition.front; + List? _audioInputs; + List? _audioOutputs; + List? _videoInputs; + MediaDevice? _selectedVideoInput; + + StreamSubscription? _subscription; + @override void initState() { super.initState(); participant.addListener(_onChange); + _subscription = Hardware.instance.onDeviceChange.stream + .listen((List devices) { + _loadDevices(devices); + }); + Hardware.instance.enumerateDevices().then(_loadDevices); } @override void dispose() { + _subscription?.cancel(); participant.removeListener(_onChange); super.dispose(); } LocalParticipant get participant => widget.participant; + void _loadDevices(List devices) async { + _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); + _audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList(); + _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); + _selectedVideoInput = _videoInputs?.first; + setState(() {}); + } + void _onChange() { // trigger refresh setState(() {}); @@ -69,6 +91,26 @@ class _ControlsWidgetState extends State { await participant.setCameraEnabled(true); } + void _selectAudioOutput(MediaDevice device) async { + await Hardware.instance.selectAudioOutput(device); + setState(() {}); + } + + void _selectAudioInput(MediaDevice device) async { + await Hardware.instance.selectAudioInput(device); + setState(() {}); + } + + void _selectVideoInput(MediaDevice device) async { + final track = participant.videoTracks.firstOrNull?.track; + if (track == null) return; + if (_selectedVideoInput?.deviceId != device.deviceId) { + await track.switchCamera(device.deviceId); + _selectedVideoInput = device; + setState(() {}); + } + } + void _toggleCamera() async { // final track = participant.videoTracks.firstOrNull?.track; @@ -211,10 +253,44 @@ class _ControlsWidgetState extends State { tooltip: 'Unpublish all', ), if (participant.isMicrophoneEnabled()) - IconButton( - onPressed: _disableAudio, - icon: const Icon(EvaIcons.mic), - tooltip: 'mute audio', + PopupMenuButton( + icon: const Icon(Icons.settings_voice), + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: null, + child: const ListTile( + leading: Icon( + EvaIcons.micOff, + color: Colors.white, + ), + title: Text('Mute Microphone'), + ), + onTap: _disableAudio, + ), + if (_audioInputs != null) + ..._audioInputs!.map((device) { + return PopupMenuItem( + value: device, + child: ListTile( + leading: (device.deviceId == + Hardware + .instance.selectedAudioInput?.deviceId) + ? const Icon( + EvaIcons.checkmarkSquare, + color: Colors.white, + ) + : const Icon( + EvaIcons.square, + color: Colors.white, + ), + title: Text(device.label), + ), + onTap: () => _selectAudioInput(device), + ); + }).toList() + ]; + }, ) else IconButton( @@ -222,11 +298,81 @@ 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), + ), + onTap: () => _selectAudioOutput(device), + ); + }).toList() + ]; + }, + ), if (participant.isCameraEnabled()) - IconButton( - onPressed: _disableVideo, + PopupMenuButton( icon: const Icon(EvaIcons.video), - tooltip: 'mute video', + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: null, + child: const ListTile( + leading: Icon( + EvaIcons.videoOff, + color: Colors.white, + ), + title: Text('Disable Camera'), + ), + onTap: _disableVideo, + ), + if (_videoInputs != null) + ..._videoInputs!.map((device) { + return PopupMenuItem( + value: device, + child: ListTile( + leading: + (device.deviceId == _selectedVideoInput?.deviceId) + ? const Icon( + EvaIcons.checkmarkSquare, + color: Colors.white, + ) + : const Icon( + EvaIcons.square, + color: Colors.white, + ), + title: Text(device.label), + ), + onTap: () => _selectVideoInput(device), + ); + }).toList() + ]; + }, ) else IconButton( diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 8d40d26..b1a4d62 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -1,7 +1,7 @@ PODS: - device_info_plus_macos (0.0.1): - FlutterMacOS - - flutter_webrtc (0.9.2): + - flutter_webrtc (0.9.4): - FlutterMacOS - WebRTC-SDK (= 104.5112.03) - FlutterMacOS (1.0.0) @@ -42,9 +42,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: device_info_plus_macos: 1ad388a1ef433505c4038e7dd9605aadd1e2e9c7 - flutter_webrtc: 335c834280a69d6db648228ab9bd0f855886f776 + flutter_webrtc: 45cd8f5825bb32054e817708bf343ceff2c9cab8 FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 - livekit_client: 1076d27c06480d4abdda77641f6586309bc2dd3f + livekit_client: d229104e54e72ec7b5ffad62c14117297b5bbb23 path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727 WebRTC-SDK: 9f50fb5a410edc38e6fbb865fe940e3010bc8e7e diff --git a/example/pubspec.lock b/example/pubspec.lock index 045523b..8e2a50e 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -176,7 +176,7 @@ packages: name: flutter_webrtc url: "https://pub.dartlang.org" source: hosted - version: "0.9.3" + version: "0.9.4" google_fonts: dependency: "direct main" description: diff --git a/ios/livekit_client.podspec b/ios/livekit_client.podspec index 4c7926b..ba8dd59 100644 --- a/ios/livekit_client.podspec +++ b/ios/livekit_client.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'livekit_client' - s.version = '1.1.0' + s.version = '1.1.1' s.summary = 'Open source platform for real-time audio and video.' s.description = 'Open source platform for real-time audio and video.' s.homepage = 'https://livekit.io/' diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 6d75931..ddc7b6e 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -4,6 +4,7 @@ library livekit_client; export 'src/core/room.dart'; export 'src/events.dart'; export 'src/exceptions.dart'; +export 'src/hardware/hardware.dart'; export 'src/livekit.dart'; export 'src/managers/event.dart'; export 'src/options.dart'; diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart new file mode 100644 index 0000000..0dc5dee --- /dev/null +++ b/lib/src/hardware/hardware.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; + +class MediaDevice { + MediaDevice(this.deviceId, this.label, this.kind); + + final String deviceId; + final String label; + final String kind; + + @override + String toString() { + return 'MediaDevice{deviceId: $deviceId, label: $label, kind: $kind}'; + } +} + +class Hardware { + Hardware._internal() { + rtc.navigator.mediaDevices.ondevicechange = _onDeviceChange; + enumerateDevices().then((devices) { + selectedAudioInput ??= + devices.where((element) => element.kind == 'audioinput').first; + selectedAudioOutput ??= + devices.where((element) => element.kind == 'audiooutput').first; + selectedVideoInput ??= + devices.where((element) => element.kind == 'videoinput').first; + }); + } + + static final Hardware instance = Hardware._internal(); + + final StreamController> onDeviceChange = + StreamController.broadcast(); + + MediaDevice? selectedAudioInput; + + MediaDevice? selectedAudioOutput; + + MediaDevice? selectedVideoInput; + + Future> enumerateDevices({String? type}) async { + var infos = await rtc.navigator.mediaDevices.enumerateDevices(); + var devices = + infos.map((e) => MediaDevice(e.deviceId, e.label, e.kind!)).toList(); + if (type != null && type.isNotEmpty) { + devices = devices.where((d) => d.kind == type).toList(); + } + return devices; + } + + Future> audioInputs() async { + return enumerateDevices(type: 'audioinput'); + } + + Future> audioOutputs() async { + return enumerateDevices(type: 'audiooutput'); + } + + Future> videoInputs() async { + return enumerateDevices(type: 'videoinput'); + } + + Future selectAudioOutput(MediaDevice device) async { + if (rtc.WebRTC.platformIsWeb) { + throw UnimplementedError('selectAudioOutput not support on web'); + } + selectedAudioOutput = device; + await rtc.Helper.selectAudioOutput(device.deviceId); + } + + Future selectAudioInput(MediaDevice device) async { + if (rtc.WebRTC.platformIsWeb || rtc.WebRTC.platformIsIOS) { + throw UnimplementedError( + 'selectAudioInput is only supported on Android/Windows/macOS'); + } + selectedAudioInput = device; + await rtc.Helper.selectAudioInput(device.deviceId); + } + + Future setSpeakerphoneOn(bool enable) async { + if (!rtc.WebRTC.platformIsAndroid || !rtc.WebRTC.platformIsIOS) { + throw UnimplementedError('setSpeakerphoneOn only support on iOS/Android'); + } + await rtc.Helper.setSpeakerphoneOn(enable); + } + + Future openCamera( + {MediaDevice? device, bool? facingMode}) async { + var constraints = { + if (facingMode != null) 'facingMode': facingMode ? 'user' : 'environment', + }; + if (device != null) { + if (rtc.WebRTC.platformIsWeb) { + constraints['deviceId'] = device.deviceId; + } else { + constraints['optional'] = [ + {'sourceId': device.deviceId} + ]; + } + } + selectedVideoInput = device; + return rtc.navigator.mediaDevices.getUserMedia({ + 'audio': false, + 'video': device != null ? constraints : true, + }); + } + + dynamic _onDeviceChange(dynamic _) async { + var devices = await enumerateDevices(); + selectedAudioInput ??= + devices.where((element) => element.kind == 'audioinput').first; + selectedAudioOutput ??= + devices.where((element) => element.kind == 'audiooutput').first; + selectedVideoInput ??= + devices.where((element) => element.kind == 'videoinput').first; + onDeviceChange.add(devices); + } +} diff --git a/lib/src/track/local/video.dart b/lib/src/track/local/video.dart index a6b0819..ff8417a 100644 --- a/lib/src/track/local/video.dart +++ b/lib/src/track/local/video.dart @@ -81,4 +81,16 @@ extension LocalVideoTrackExt on LocalVideoTrack { options.copyWith(cameraPosition: position), ); } + + Future switchCamera(String deviceId) async { + final options = currentOptions; + if (options is! CameraCaptureOptions) { + logger.warning('Not a camera track'); + return; + } + + await restartTrack( + options.copyWith(deviceId: deviceId), + ); + } } diff --git a/macos/livekit_client.podspec b/macos/livekit_client.podspec index 395f1c2..ebdaaaf 100644 --- a/macos/livekit_client.podspec +++ b/macos/livekit_client.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'livekit_client' - s.version = '1.1.0' + s.version = '1.1.1' s.summary = 'Open source platform for real-time audio and video.' s.description = 'Open source platform for real-time audio and video.' s.homepage = 'https://livekit.io/' diff --git a/pubspec.lock b/pubspec.lock index adb1c83..2d800f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,7 +218,7 @@ packages: name: flutter_webrtc url: "https://pub.dartlang.org" source: hosted - version: "0.9.3" + version: "0.9.4" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 90438f7..eb85d96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: livekit_client description: Flutter Client SDK for LiveKit. Build real-time video and audio into your apps. Supports iOS, Android, and Web. -version: 1.1.0-hotfix +version: 1.1.1 homepage: https://livekit.io environment: @@ -22,7 +22,7 @@ dependencies: uuid: ^3.0.6 synchronized: ^3.0.0 protobuf: ^2.0.1 - flutter_webrtc: ^0.9.3 + flutter_webrtc: ^0.9.4 dart_webrtc: ^1.0.7 device_info_plus: ^3.2.3