Skip to content

Commit f2f6a15

Browse files
authored
fix: reset iOS audio route on call session start/end to prevent sticky speaker (#876)
1 parent dcdd2f9 commit f2f6a15

2 files changed

Lines changed: 52 additions & 5 deletions

File tree

lib/features/call/bloc/call_bloc.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,58 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im
292292
if (change.nextState.activeCalls.length < change.currentState.activeCalls.length) {
293293
onCallEnded?.call();
294294
}
295+
296+
/// Manages global side effects triggered by call lifecycle transitions.
297+
/// Key responsibility:
298+
/// - **iOS Audio Reset:** On the start of the *first* call, it forces the
299+
/// audio route back to the Receiver (Earpiece). This prevents the "sticky speaker"
300+
/// issue where iOS remembers the Speaker output from a previous session.
301+
_handleCallLifecycleTransitions(
302+
previousCalls: change.currentState.activeCalls,
303+
currentCalls: change.nextState.activeCalls,
304+
);
305+
}
306+
307+
/// Analyzes changes in the active call list to trigger specific lifecycle hooks.
308+
///
309+
/// This method identifies keys transitions:
310+
/// * **First Call Started (`0 -> 1`):** A cold start of the calling session.
311+
/// Crucial for initializing hardware resources (e.g., resetting speaker output on iOS).
312+
/// * **Last Call Ended (`N -> 0`):** The termination of the calling session.
313+
/// Used for global cleanup and resource release.
314+
void _handleCallLifecycleTransitions({
315+
required List<ActiveCall> previousCalls,
316+
required List<ActiveCall> currentCalls,
317+
}) {
318+
final wasEmpty = previousCalls.isEmpty;
319+
final isEmpty = currentCalls.isEmpty;
320+
321+
if (wasEmpty && !isEmpty) {
322+
_onFirstCallStarted();
323+
}
324+
325+
if (!wasEmpty && isEmpty) {
326+
_onLastCallEnded();
327+
}
328+
}
329+
330+
/// Triggered when the first active call is established (0 -> 1 active calls).
331+
///
332+
/// * **iOS:** Forces the audio output to the Receiver (Earpiece) via `Helper.setSpeakerphoneOn(false)`.
333+
/// This is a critical hard-reset to fix the "sticky speaker" issue where iOS
334+
/// retains the speaker route from a previous, unrelated session.
335+
void _onFirstCallStarted() {
336+
_logger.info(() => 'Lifecycle: First call started');
337+
if (Platform.isIOS) Helper.setSpeakerphoneOn(false);
338+
}
339+
340+
/// Triggered when the last remaining active call ends (N -> 0 active calls).
341+
///
342+
/// * **iOS:** Resets audio output to the default Receiver state to ensure clean state
343+
/// for future calls, preventing state bleeding between sessions.
344+
void _onLastCallEnded() {
345+
_logger.info(() => 'Lifecycle: Last call ended');
346+
if (Platform.isIOS) Helper.setSpeakerphoneOn(false);
295347
}
296348

297349
void _handleSignalingSessionError({required CallServiceState previous, required CallServiceState current}) {

lib/features/call/utils/user_media_builder.dart

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,6 @@ class DefaultUserMediaBuilder implements UserMediaBuilder {
3333
await Helper.setAppleAudioConfiguration(
3434
AppleAudioConfiguration(appleAudioMode: video ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat),
3535
);
36-
37-
// REMOVED: await Helper.setSpeakerphoneOn(video);
38-
// Reason: Calling this forcibly overrides the audio route, causing sound
39-
// to play via speaker even when headphones are connected on iOS.
40-
// AppleAudioConfiguration handles the default routing correctly.
4136
}
4237

4338
return localStream;

0 commit comments

Comments
 (0)