Skip to content

Commit baf4a05

Browse files
authored
feat(llc): Make sure the audio input device is set even when the user joins muted (#1024)
* Make sure the audio input device is set even when the user joins muted * introduced trackmissingexception * changelog added * fix * added unit tests * tweak
1 parent 973a6db commit baf4a05

File tree

7 files changed

+155
-8
lines changed

7 files changed

+155
-8
lines changed

packages/stream_video/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
🐞 Fixed
77
* Fixed an issue where the last reaction was removed too fast when a user sends multiple reactions quickly after each other.
8-
* Fixed an issue where toggling camera enabled quickly could cause AVCaptureMultiCamSession to crash
8+
* Fixed an issue where toggling camera enabled quickly could cause AVCaptureMultiCamSession to crash.
99
* Fixed an issue where the default camera selection would occasionally be incorrect even when properly configured.
10+
* Fixed an issue where changing the audio input device while muted from the start of a call would not apply the new device when unmuting. The selected device will now be correctly set upon unmuting.
1011

1112
## 0.10.0
1213

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2542,6 +2542,11 @@ class Call {
25422542
Result.error('Session is null');
25432543

25442544
if (result.isSuccess) {
2545+
// Make sure the audio input device is set
2546+
if (enabled && _connectOptions.audioInputDevice != null) {
2547+
await setAudioInputDevice(_connectOptions.audioInputDevice!);
2548+
}
2549+
25452550
_sfuStatsTimers.add(
25462551
Future<void>.delayed(const Duration(seconds: 3)).then((_) {
25472552
if (result.getDataOrNull()!.mediaTrack.enabled) {
@@ -2616,13 +2621,22 @@ class Call {
26162621
final result = await _session?.setAudioInputDevice(device) ??
26172622
Result.error('Session is null');
26182623

2619-
if (result.isSuccess) {
2620-
_connectOptions = connectOptions.copyWith(audioInputDevice: device);
2624+
_connectOptions = _connectOptions.copyWith(audioInputDevice: device);
26212625

2626+
if (result.isSuccess) {
26222627
_stateManager.participantSetAudioInputDevice(device: device);
2628+
return const Result.success(none);
2629+
} else {
2630+
if (result.getErrorOrNull()
2631+
case VideoErrorWithCause(cause: TrackMissingException())) {
2632+
// If the track is null, it most probably means that the user
2633+
// joined the call muted and the audio track was not created.
2634+
// We will set the audio input device when the user unmutes.
2635+
return const Result.success(none);
2636+
} else {
2637+
return result;
2638+
}
26232639
}
2624-
2625-
return result;
26262640
}
26272641

26282642
Future<Result<None>> setAudioOutputDevice(RtcMediaDevice device) async {

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,8 +1053,11 @@ extension RtcManagerTrackHelper on RtcManager {
10531053
}) async {
10541054
final track = getPublisherTrackByType(SfuTrackType.audio);
10551055
if (track == null) {
1056-
_logger.w(() => '[setMicrophoneDeviceId] rejected (track is null)');
1057-
return Result.error('Track is null');
1056+
_logger.d(() => '[setMicrophoneDeviceId] rejected (track is null)');
1057+
return Result.errorWithCause(
1058+
'Track is null',
1059+
TrackMissingException(trackType: SfuTrackType.audio),
1060+
);
10581061
}
10591062

10601063
if (track is! RtcLocalAudioTrack) {
@@ -1338,3 +1341,13 @@ extension on RtcLocalTrack<VideoConstraints> {
13381341
return dimension;
13391342
}
13401343
}
1344+
1345+
class TrackMissingException implements Exception {
1346+
TrackMissingException({required this.trackType});
1347+
final SfuTrackType trackType;
1348+
1349+
@override
1350+
String toString() {
1351+
return 'TrackMissingException: Track with type "$trackType" is missing.';
1352+
}
1353+
}

packages/stream_video/test/src/call/call_test.dart

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
44
import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart';
55
import 'package:mocktail/mocktail.dart';
66
import 'package:rxdart/rxdart.dart';
7+
import 'package:stream_video/src/errors/video_error.dart';
8+
import 'package:stream_video/src/webrtc/rtc_manager.dart';
79
import 'package:stream_video/stream_video.dart';
810

911
import '../../test_helpers.dart';
@@ -68,6 +70,112 @@ void main() {
6870
await internetStatusController.close();
6971
});
7072

73+
test('should set audio input device', () async {
74+
final internetStatusController =
75+
BehaviorSubject<InternetStatus>.seeded(InternetStatus.connected);
76+
77+
final coordinatorClient = setupMockCoordinatorClient();
78+
final callSession = setupMockCallSession();
79+
80+
when(() => callSession.setAudioInputDevice(any<RtcMediaDevice>()))
81+
.thenAnswer((_) => Future.value(const Result.success(none)));
82+
83+
final call = createTestCall(
84+
networkMonitor: setupMockInternetConnection(
85+
statusStream: internetStatusController,
86+
),
87+
coordinatorClient: coordinatorClient,
88+
sessionFactory: setupMockSessionFactory(
89+
callSession: callSession,
90+
),
91+
);
92+
93+
const audioDevice = RtcMediaDevice(
94+
id: 'id',
95+
label: 'label',
96+
kind: RtcMediaDeviceKind.audioInput,
97+
);
98+
99+
await call.join();
100+
101+
final result = await call.setAudioInputDevice(audioDevice);
102+
103+
expect(result, isA<Result<None>>());
104+
expect(result.isSuccess, isTrue);
105+
106+
expect(call.connectOptions.audioInputDevice, audioDevice);
107+
108+
await internetStatusController.close();
109+
});
110+
111+
test(
112+
'should set audio input device when track missing, set later when unmute',
113+
() async {
114+
final internetStatusController =
115+
BehaviorSubject<InternetStatus>.seeded(InternetStatus.connected);
116+
117+
final coordinatorClient = setupMockCoordinatorClient();
118+
final callSession = setupMockCallSession();
119+
final mockPermissionManager = MockPermissionsManager();
120+
121+
when(() => mockPermissionManager.hasPermission(CallPermission.sendAudio))
122+
.thenAnswer((_) => true);
123+
124+
final resultArray = <Result<None>>[
125+
Result.failure(
126+
VideoErrorWithCause(
127+
message: '',
128+
cause: TrackMissingException(trackType: SfuTrackType.audio),
129+
),
130+
),
131+
const Result.success(none),
132+
];
133+
134+
when(() => callSession.setAudioInputDevice(any<RtcMediaDevice>()))
135+
.thenAnswer(
136+
(_) => Future.value(resultArray.removeAt(0)),
137+
);
138+
139+
when(() => callSession.setMicrophoneEnabled(any()))
140+
.thenAnswer((_) => Future.value(Result.success(MockRtcLocalTrack())));
141+
142+
final call = createTestCall(
143+
permissionManager: mockPermissionManager,
144+
networkMonitor: setupMockInternetConnection(
145+
statusStream: internetStatusController,
146+
),
147+
coordinatorClient: coordinatorClient,
148+
sessionFactory: setupMockSessionFactory(
149+
callSession: callSession,
150+
),
151+
);
152+
153+
const audioDevice = RtcMediaDevice(
154+
id: 'id',
155+
label: 'label',
156+
kind: RtcMediaDeviceKind.audioInput,
157+
);
158+
159+
await call.join();
160+
161+
final result = await call.setAudioInputDevice(audioDevice);
162+
163+
expect(result, isA<Result<None>>());
164+
expect(result.isSuccess, isTrue);
165+
166+
expect(call.connectOptions.audioInputDevice, audioDevice);
167+
168+
await call.setMicrophoneEnabled(enabled: true);
169+
170+
verifyInOrder([
171+
() => callSession.setAudioInputDevice(audioDevice),
172+
() => callSession.setMicrophoneEnabled(true),
173+
() => callSession.setAudioInputDevice(audioDevice),
174+
]);
175+
176+
await internetStatusController.close();
177+
});
178+
71179
test(
72180
'should handle concurrent setCameraEnabled calls without race conditions',
73181
() async {

packages/stream_video/test/src/call/call_test_helpers.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const defaultCredentials = CallCredentials(
3636
),
3737
);
3838

39+
const defaultMediaDevice = RtcMediaDevice(
40+
id: 'fallback-device',
41+
label: 'Fallback Device',
42+
kind: RtcMediaDeviceKind.audioInput,
43+
groupId: 'fallback-group',
44+
);
45+
3946
const defaultUserInfo = UserInfo(id: 'testUserId');
4047

4148
void registerMockFallbackValues() {
@@ -51,6 +58,7 @@ void registerMockFallbackValues() {
5158
StatsOptions(enableRtcStats: false, reportingIntervalMs: 500),
5259
);
5360
registerFallbackValue(SfuReconnectionStrategy.fast);
61+
registerFallbackValue(defaultMediaDevice);
5462
}
5563

5664
Call createStubCall({

packages/stream_video/test/test_helpers.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class MockWebSocketChannel extends Mock implements WebSocketChannel {}
4444

4545
class MockWebSocketSink extends Mock implements WebSocketSink {}
4646

47+
class MockRtcLocalTrack extends Mock implements RtcLocalTrack {}
48+
4749
/// Helper function to create CallDetails for testing
4850
CallDetails createTestCallDetails({
4951
required String createdByUserId,

packages/stream_video_flutter/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
🐞 Fixed
44
* (iOS) Fixed Picture-in-Picture (PiP) issue where remote participants joining during active PiP mode would not have their video tracks displayed properly.
55
* (iOS) Fixed a visual issue where the Picture-in-Picture view displayed an empty container when participant name and microphone indicator settings were disabled.
6-
* Fixed an issue where toggling camera enabled quickly could cause AVCaptureMultiCamSession to crash
6+
* Fixed an issue where toggling camera enabled quickly could cause AVCaptureMultiCamSession to crash.
7+
* Fixed an issue where changing the audio input device while muted from the start of a call would not apply the new device when unmuting. The selected device will now be correctly set upon unmuting.
78

89
✅ Added
910
* Added support for customization of display name for ringing notifications by providing `display_name` custom data to the call. See the [documentation](https://getstream.io/video/docs/flutter/advanced/incoming-calls/customization/#display-name-customization) for details.

0 commit comments

Comments
 (0)