Skip to content

Commit f529cca

Browse files
authored
chore: default audio logic in line with other sdks (#970)
* default audio logic in line with other sdks * tweak * webrtc dependency bump * added unit tests for applying call settings * test fix
1 parent c6343fa commit f529cca

File tree

18 files changed

+814
-85
lines changed

18 files changed

+814
-85
lines changed

dogfooding/lib/widgets/settings_menu/settings_menu.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import 'package:flutter_dogfooding/widgets/settings_menu/closed_captions_menu_it
1010
import 'package:flutter_dogfooding/widgets/settings_menu/noise_cancellation_menu_item.dart';
1111
import 'package:flutter_dogfooding/widgets/settings_menu/settings_menu_item.dart';
1212
import 'package:flutter_dogfooding/widgets/settings_menu/standard_action_menu_item.dart';
13-
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
13+
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
14+
hide CurrentPlatform;
1415
import 'package:stream_video_flutter/stream_video_flutter.dart';
1516

1617
import '../../utils/feedback_dialog.dart';
@@ -206,6 +207,11 @@ class _SettingsMenuState extends State<SettingsMenu> {
206207
)
207208
] else
208209
ChooseAudioOutputMenuItem(onPressed: () {
210+
if (CurrentPlatform.isIos) {
211+
_deviceNotifier.triggeriOSAudioRouteSelectionUI();
212+
return;
213+
}
214+
209215
setState(() {
210216
showAudioOutputs = true;
211217
});

dogfooding/linux/flutter/generated_plugin_registrant.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <record_linux/record_linux_plugin.h>
1414
#include <stream_webrtc_flutter/flutter_web_r_t_c_plugin.h>
1515
#include <url_launcher_linux/url_launcher_plugin.h>
16+
#include <volume_controller/volume_controller_plugin.h>
1617

1718
void fl_register_plugins(FlPluginRegistry* registry) {
1819
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
@@ -36,4 +37,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
3637
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
3738
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
3839
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
40+
g_autoptr(FlPluginRegistrar) volume_controller_registrar =
41+
fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
42+
volume_controller_plugin_register_with_registrar(volume_controller_registrar);
3943
}

dogfooding/linux/flutter/generated_plugins.cmake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
1010
record_linux
1111
stream_webrtc_flutter
1212
url_launcher_linux
13+
volume_controller
1314
)
1415

1516
list(APPEND FLUTTER_FFI_PLUGIN_LIST

melos.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ command:
2222
device_info_plus: ">=10.1.2 <12.0.0"
2323
share_plus: ^11.0.0
2424
stream_chat_flutter: ^9.8.0
25-
stream_webrtc_flutter: ^1.0.5
25+
stream_webrtc_flutter: ^1.0.6
2626

2727
scripts:
2828
postclean:

packages/stream_video/lib/src/call/call.dart

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class Call {
101101
RetryPolicy? retryPolicy,
102102
SdpPolicy? sdpPolicy,
103103
CallPreferences? preferences,
104+
RtcMediaDeviceNotifier? rtcMediaDeviceNotifier,
104105
}) {
105106
streamLog.i(_tag, () => '<factory> callCid: $callCid');
106107
return Call._internal(
@@ -111,6 +112,7 @@ class Call {
111112
retryPolicy: retryPolicy,
112113
sdpPolicy: sdpPolicy,
113114
preferences: preferences,
115+
rtcMediaDeviceNotifier: rtcMediaDeviceNotifier,
114116
);
115117
}
116118

@@ -125,6 +127,7 @@ class Call {
125127
RetryPolicy? retryPolicy,
126128
SdpPolicy? sdpPolicy,
127129
CallPreferences? preferences,
130+
RtcMediaDeviceNotifier? rtcMediaDeviceNotifier,
128131
}) {
129132
streamLog.i(_tag, () => '<factory> created: $data');
130133
return Call._internal(
@@ -135,6 +138,7 @@ class Call {
135138
retryPolicy: retryPolicy,
136139
sdpPolicy: sdpPolicy,
137140
preferences: preferences,
141+
rtcMediaDeviceNotifier: rtcMediaDeviceNotifier,
138142
).also(
139143
(it) => it._stateManager.updateFromCallCreatedData(
140144
data,
@@ -154,6 +158,7 @@ class Call {
154158
RetryPolicy? retryPolicy,
155159
SdpPolicy? sdpPolicy,
156160
CallPreferences? preferences,
161+
RtcMediaDeviceNotifier? rtcMediaDeviceNotifier,
157162
}) {
158163
streamLog.i(_tag, () => '<factory> created: $data');
159164
return Call._internal(
@@ -164,6 +169,7 @@ class Call {
164169
retryPolicy: retryPolicy,
165170
sdpPolicy: sdpPolicy,
166171
preferences: preferences,
172+
rtcMediaDeviceNotifier: rtcMediaDeviceNotifier,
167173
).also((it) => it._stateManager.lifecycleCallRinging(data));
168174
}
169175

@@ -176,6 +182,7 @@ class Call {
176182
SdpPolicy? sdpPolicy,
177183
CallPreferences? preferences,
178184
CallCredentials? credentials,
185+
RtcMediaDeviceNotifier? rtcMediaDeviceNotifier,
179186
}) {
180187
final finalCallPreferences = preferences ?? DefaultCallPreferences();
181188
final finalRetryPolicy = retryPolicy ?? const RetryPolicy();
@@ -204,6 +211,8 @@ class Call {
204211
retryPolicy: finalRetryPolicy,
205212
sdpPolicy: finalSdpPolicy,
206213
permissionManager: permissionManager,
214+
rtcMediaDeviceNotifier:
215+
rtcMediaDeviceNotifier ?? RtcMediaDeviceNotifier.instance,
207216
);
208217
}
209218

@@ -215,6 +224,7 @@ class Call {
215224
required this.networkMonitor,
216225
required RetryPolicy retryPolicy,
217226
required SdpPolicy sdpPolicy,
227+
required RtcMediaDeviceNotifier rtcMediaDeviceNotifier,
218228
CallCredentials? credentials,
219229
}) : _sessionFactory = CallSessionFactory(
220230
callCid: stateManager.callState.callCid,
@@ -228,6 +238,7 @@ class Call {
228238
_streamVideo = streamVideo,
229239
_retryPolicy = retryPolicy,
230240
_credentials = credentials,
241+
_rtcMediaDeviceNotifier = rtcMediaDeviceNotifier,
231242
dynascaleManager = DynascaleManager(stateManager: stateManager) {
232243
streamLog.i(_tag, () => '<init> state: ${stateManager.callState}');
233244

@@ -252,6 +263,7 @@ class Call {
252263
final PermissionsManager _permissionsManager;
253264
final DynascaleManager dynascaleManager;
254265
final InternetConnection networkMonitor;
266+
final RtcMediaDeviceNotifier _rtcMediaDeviceNotifier;
255267

256268
CallCredentials? _credentials;
257269
CallSession? _session;
@@ -1458,9 +1470,7 @@ class Call {
14581470
}
14591471

14601472
Future<void> _applyCallSettingsToConnectOptions(CallSettings settings) async {
1461-
// Apply defaul audio output and input devices
1462-
final mediaDevicesResult =
1463-
await RtcMediaDeviceNotifier.instance.enumerateDevices();
1473+
final mediaDevicesResult = await _rtcMediaDeviceNotifier.enumerateDevices();
14641474

14651475
final mediaDevices = mediaDevicesResult.fold(
14661476
success: (success) => success.data,
@@ -1477,38 +1487,65 @@ class Call {
14771487
.where((d) => d.kind == RtcMediaDeviceKind.videoInput)
14781488
.toList();
14791489

1480-
var defaultAudioOutput = audioOutputs.firstWhereOrNull((device) {
1481-
if (settings.audio.defaultDevice ==
1482-
AudioSettingsRequestDefaultDeviceEnum.speaker) {
1483-
return device.id.equalsIgnoreCase(
1484-
AudioSettingsRequestDefaultDeviceEnum.speaker.value,
1490+
/// Determines if the speaker should be enabled based on a priority hierarchy of
1491+
/// settings.
1492+
///
1493+
/// The priority order is as follows:
1494+
/// 1. If video camera is set to be on by default, speaker is enabled
1495+
/// 2. If audio speaker is set to be on by default, speaker is enabled
1496+
/// 3. If the default audio device is set to speaker, speaker is enabled
1497+
final speakerOnWithSettingsPriority = settings.video.cameraDefaultOn ||
1498+
settings.audio.speakerDefaultOn ||
1499+
settings.audio.defaultDevice ==
1500+
AudioSettingsRequestDefaultDeviceEnum.speaker;
1501+
1502+
// Determine default audio output with priority:
1503+
// 1. External device (if available)
1504+
var defaultAudioOutput =
1505+
audioOutputs.firstWhereOrNull((device) => device.isExternal);
1506+
1507+
if (defaultAudioOutput == null) {
1508+
// 2. Speaker (if settings indicate it should be used)
1509+
if (speakerOnWithSettingsPriority) {
1510+
defaultAudioOutput = audioOutputs.firstWhereOrNull(
1511+
(device) => device.id.equalsIgnoreCase(
1512+
AudioSettingsRequestDefaultDeviceEnum.speaker.value,
1513+
),
1514+
);
1515+
} else {
1516+
// 3. First non-speaker device
1517+
defaultAudioOutput = audioOutputs.firstWhereOrNull(
1518+
(device) => !device.id.equalsIgnoreCase(
1519+
AudioSettingsRequestDefaultDeviceEnum.speaker.value,
1520+
),
14851521
);
14861522
}
1523+
}
14871524

1488-
return !device.id.equalsIgnoreCase(
1489-
AudioSettingsRequestDefaultDeviceEnum.speaker.value,
1490-
);
1491-
});
1525+
final defaultAudioOutputIsExternal =
1526+
defaultAudioOutput?.isExternal ?? false;
14921527

1493-
if (defaultAudioOutput == null && audioOutputs.isNotEmpty) {
1494-
defaultAudioOutput = audioOutputs.first;
1528+
// iOS doesn't allow implicitly setting the default audio output,
1529+
// if external device is connected we trust the OS to set it as default.
1530+
if (defaultAudioOutputIsExternal && CurrentPlatform.isIos) {
1531+
defaultAudioOutput = null;
14951532
}
14961533

1534+
// Match the default audio input with the default audio output if possible
1535+
final defaultAudioInput = audioInputs
1536+
.firstWhereOrNull((d) => d.label == defaultAudioOutput?.label);
1537+
14971538
var defaultVideoInput = videoInputs.firstWhereOrNull(
14981539
(device) => device.label
14991540
.toLowerCase()
15001541
.contains(settings.video.cameraFacing.value.toLowerCase()),
15011542
);
15021543

1544+
// If it's not front or back then take one of the external cameras
15031545
if (defaultVideoInput == null && videoInputs.length > 2) {
1504-
// If it's not front or back then take one of the external cameras
15051546
defaultVideoInput = videoInputs.last;
15061547
}
15071548

1508-
final defaultAudioInput = audioInputs
1509-
.firstWhereOrNull((d) => d.label == defaultAudioOutput?.label) ??
1510-
audioInputs.firstOrNull;
1511-
15121549
_connectOptions = connectOptions.copyWith(
15131550
camera: TrackOption.fromSetting(
15141551
enabled: settings.video.cameraDefaultOn,
@@ -1523,7 +1560,8 @@ class Call {
15231560
VideoSettingsRequestCameraFacingEnum.front
15241561
? FacingMode.user
15251562
: FacingMode.environment,
1526-
speakerDefaultOn: settings.audio.speakerDefaultOn,
1563+
speakerDefaultOn:
1564+
!defaultAudioOutputIsExternal && speakerOnWithSettingsPriority,
15271565
targetResolution: settings.video.targetResolution,
15281566
screenShareTargetResolution: settings.screenShare.targetResolution,
15291567
);
@@ -2203,7 +2241,7 @@ class Call {
22032241
await result.fold(
22042242
success: (success) async {
22052243
final mediaDevicesResult =
2206-
await RtcMediaDeviceNotifier.instance.enumerateDevices();
2244+
await _rtcMediaDeviceNotifier.enumerateDevices();
22072245

22082246
final mediaDevices = mediaDevicesResult.fold(
22092247
success: (success) => success.data,
@@ -2385,10 +2423,6 @@ class Call {
23852423
_connectOptions = _connectOptions.copyWith(
23862424
microphone: enabled ? TrackOption.enabled() : TrackOption.disabled(),
23872425
);
2388-
2389-
if (_connectOptions.audioOutputDevice != null) {
2390-
await setAudioOutputDevice(_connectOptions.audioOutputDevice!);
2391-
}
23922426
}
23932427

23942428
return result.map((_) => none);

packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import 'package:equatable/equatable.dart';
22

3+
import '../../platform_detector/platform_detector.dart';
4+
35
enum RtcMediaDeviceKind {
46
audioInput('audioinput'),
57
audioOutput('audiooutput'),
@@ -41,4 +43,47 @@ class RtcMediaDevice with EquatableMixin {
4143

4244
@override
4345
List<Object?> get props => [id, kind, groupId, label];
46+
47+
/// The set of external audio ports that are considered external outputs
48+
static const Set<String> iOSExternalPorts = {
49+
'bluetoothA2DP',
50+
'bluetoothLE',
51+
'bluetoothHFP',
52+
'carAudio',
53+
'headphones',
54+
};
55+
56+
/// The set of external audio device types that are considered external outputs
57+
static const Set<String> androidExternalAudioDeviceType = {
58+
'bluetooth',
59+
'wired-headset',
60+
};
61+
62+
/// Whether this device represents an external audio output.
63+
/// Checks if the device label contains any of the external port types.
64+
bool get isExternal {
65+
if (groupId == null) {
66+
return false;
67+
}
68+
69+
if (CurrentPlatform.isIos) {
70+
return iOSExternalPorts.any(
71+
(port) => groupId!.toLowerCase().contains(
72+
port.toLowerCase(),
73+
),
74+
);
75+
} else if (CurrentPlatform.isAndroid) {
76+
return androidExternalAudioDeviceType.any(
77+
(type) => groupId!.toLowerCase().contains(
78+
type.toLowerCase(),
79+
),
80+
);
81+
} else {
82+
return groupId!.toLowerCase().contains('bluetooth');
83+
}
84+
}
85+
86+
bool get isSpeaker => id.toLowerCase() == 'speaker';
87+
88+
bool get isEarpiece => id.toLowerCase() == 'earpiece';
4489
}

packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,8 @@ class RtcMediaDeviceNotifier {
8787
Future<Result<List<RtcMediaDevice>>> videoInputs() {
8888
return enumerateDevices(kind: RtcMediaDeviceKind.videoInput);
8989
}
90+
91+
Future<void> triggeriOSAudioRouteSelectionUI() {
92+
return rtc.Helper.triggeriOSAudioRouteSelectionUI();
93+
}
9094
}

packages/stream_video/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ dependencies:
3131
rxdart: ^0.28.0
3232
sdp_transform: ^0.3.2
3333
state_notifier: ^1.0.0
34-
stream_webrtc_flutter: ^1.0.5
34+
stream_webrtc_flutter: ^1.0.6
3535
synchronized: ^3.1.0
3636
system_info2: ^4.0.0
3737
tart: ^0.5.1

0 commit comments

Comments
 (0)