Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions dogfooding/ios/Flutter/AppFrameworkInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
4 changes: 0 additions & 4 deletions dogfooding/linux/flutter/generated_plugin_registrant.cc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
#include <record_linux/record_linux_plugin.h>
#include <stream_webrtc_flutter/flutter_web_r_t_c_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <volume_controller/volume_controller_plugin.h>

void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
Expand All @@ -37,7 +36,4 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) volume_controller_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
volume_controller_plugin_register_with_registrar(volume_controller_registrar);
}
2 changes: 1 addition & 1 deletion dogfooding/linux/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ list(APPEND FLUTTER_PLUGIN_LIST
record_linux
stream_webrtc_flutter
url_launcher_linux
volume_controller
)

list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)

set(PLUGIN_BUNDLED_LIBRARIES)
Expand Down
2 changes: 1 addition & 1 deletion dogfooding/windows/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ list(APPEND FLUTTER_PLUGIN_LIST
stream_webrtc_flutter
thumblr_windows
url_launcher_windows
volume_controller
)

list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)

set(PLUGIN_BUNDLED_LIBRARIES)
Expand Down
5 changes: 4 additions & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ command:
device_info_plus: ^12.1.0
share_plus: ^11.0.0
stream_chat_flutter: ^9.17.0
stream_webrtc_flutter: ^2.2.6
stream_webrtc_flutter:
git:
url: https://github.com/GetStream/webrtc-flutter.git
ref: chore/per-call-pc-factory
stream_video_flutter: ^1.3.3
stream_video_noise_cancellation: ^1.3.3
stream_video_push_notification: ^1.3.3
Expand Down
13 changes: 13 additions & 0 deletions packages/stream_video/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## Unreleased

### ✅ Added
* Added `TrackDisableMode` enum and optional `disableMode` parameter to `Call.setMicrophoneEnabled()`. Allows integrators to choose between releasing the microphone hardware on mute (`TrackDisableMode.stopTracks`, the default) or keeping the capture session alive (`TrackDisableMode.disableTracks`). The latter avoids the brief iOS `AVAudioSession` teardown that can duck playback of other participants for ~1–2 seconds — recommended for audio rooms and other playback-sensitive use cases. Note: `disableTracks` keeps the system microphone indicator visible while muted because the capture hardware remains active.

### 🐞 Fixed
* Fixed sibling-call audio capture being silently broken when another concurrently-active call ended (e.g. a 1:1 ringing call ending alongside a running livestream, or a previous ringing call ending before a new one was accepted). `RtcManager.dispose()` now skips the `pc.removeTrack(sender)` step on the publisher PC when it's about to dispose the PC entirely. The explicit `removeTrack` triggers libwebrtc's per-`Call.AudioState` to issue `ADM.StopRecording()` on the **process-wide shared** `AudioDeviceModule` — with no refcount across PCs — which left every still-active call wired against a stopped capture pipeline. Wholesale `pc.dispose()` doesn't take the same lifecycle path and tears down the PC cleanly. Implemented via a new `removeFromPc` parameter on `unpublishTrack` (default `true`; `false` when called from `dispose`). See `docs/audio-lifecycle-analysis.md` for the full investigation trail.
* Fixed a sibling call's audio breaking when a ringing 1:1 call ended via `dropIfAloneInRingingFlow` (the remote party hung up first). `Call.end()` and `Call.leave()` now share a single `_disconnect` cleanup path, so both honor `_leaveCallTriggered`, complete `_callLifecycleCompleter`, and short-circuit consistently when the call is already disconnected — previously `Call.end()` skipped these guards, which caused races with concurrent reconnect handlers and with re-enabling the mic on a sibling active call.
* Made the audio processor teardown in `Call._clear` multi-call aware. The audio processor is owned by `StreamVideo`, not by an individual `Call`, so disabling it on one call's teardown silently dropped noise cancellation on any other still-active call. `_clear` now only stops the global processor when no other active call is configured to use `NoiceCancellationSettingsMode.autoOn`.

### 🔄 Changed
* `Call.leave()` and `Call.end()` now actually wait for the underlying native teardown before returning. Previously `Call._clear` fire-and-forgot `_session.dispose()`, and `CallSession.close` itself fire-and-forgot the WebRTC manager dispose and the SFU WebSocket disconnect, so callers could observe `leave()`/`end()` "complete" while peer connections, local audio tracks, and audio sources were still being torn down on the native side. With this change, awaiting `Call.leave()` / `Call.end()` is enough to guarantee the native cleanup has finished — important when the next thing the integrator does is touch a sibling active call's audio (e.g. resuming a livestream's mic after a 1:1 ringing call ends). Leave/end will take slightly longer to return; if you need fire-and-forget semantics, wrap the call in `unawaited(...)` yourself.

## 1.3.3

### 🐞 Fixed
Expand Down
138 changes: 100 additions & 38 deletions packages/stream_video/lib/src/call/call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import '../utils/subscriptions.dart';
import '../webrtc/media/media_constraints.dart';
import '../webrtc/model/rtc_video_dimension.dart';
import '../webrtc/model/rtc_video_parameters.dart';
import '../webrtc/model/track_disable_mode.dart';
import '../webrtc/rtc_audio_api/rtc_audio_api.dart' as rtc_audio;
import '../webrtc/rtc_manager.dart';
import '../webrtc/rtc_media_device/rtc_media_device.dart';
Expand Down Expand Up @@ -278,6 +279,7 @@ class Call {

CallCredentials? _credentials;
CallSession? _session;
CallSession? get callSession => _session;
CallSession? _previousSession;

StatsOptions? _sfuStatsOptions;
Expand Down Expand Up @@ -847,22 +849,34 @@ class Call {

/// Ends the call for all participants.
Future<Result<None>> end({String? reason}) async {
final state = this.state.value;
_logger.d(() => '[end] status: ${state.status}');
_logger.d(() => '[end] status: ${state.value.status}');

if (state.status is! CallStatusActive) {
_logger.w(() => '[end] rejected (invalid status): ${state.status}');
return Result.error('invalid status: ${state.status}');
if (state.value.status is! CallStatusActive) {
_logger.w(() => '[end] rejected (invalid status): ${state.value.status}');
return Result.error('invalid status: ${state.value.status}');
}

_session?.leave(reason: reason ?? 'user is ending the call');
await _clear('end');
try {
final didDisconnect = await _disconnect(
sfuLeaveReason: reason ?? 'user is ending the call',
);

final result = await _permissionsManager.endCall();
_stateManager.lifecycleCallEnded();
// If another disconnect already ran (or is running), don't fire the
// server-side endCall a second time and don't re-emit the lifecycle
// event.
if (!didDisconnect) {
_logger.v(() => '[end] disconnect short-circuited');
return const Result.success(none);
}

_logger.v(() => '[end] completed: $result');
return result;
final result = await _permissionsManager.endCall();
_stateManager.lifecycleCallEnded();

_logger.v(() => '[end] completed: $result');
return result;
} finally {
_leaveCallTriggered = false;
}
}

/// Joins the call.
Expand Down Expand Up @@ -1170,6 +1184,9 @@ class Call {
networkMonitor: networkMonitor,
streamVideo: _streamVideo,
statsOptions: _sfuStatsOptions!,
audioConfigurationPolicy:
_stateManager.callState.preferences.audioConfigurationPolicy ??
_streamVideo.options.audioConfigurationPolicy,
leftoverTraceRecords:
_previousSession
?.getTrace()
Expand Down Expand Up @@ -2041,41 +2058,58 @@ class Call {
///
/// - [reason]: optional reason for leaving the call
Future<Result<None>> leave({DisconnectReason? reason}) async {
try {
if (_leaveCallTriggered) {
_logger.i(() => '[leave] rejected (already leaving call)');
return const Result.success(none);
}
_logger.i(() => '[leave] reason: $reason');

_leaveCallTriggered = true;
try {
final didDisconnect = await _disconnect(
sfuLeaveReason: _sfuLeaveReason(reason),
);

// Complete the leave completer to cancel ongoing operations
if (!_callLifecycleCompleter.isCompleted) {
_callLifecycleCompleter.complete();
if (didDisconnect) {
_stateManager.lifecycleCallDisconnected(reason: reason);
}

final state = this.state.value;
_logger.i(() => '[leave] state: $state');
_logger.v(() => '[leave] finished');
return const Result.success(none);
} finally {
_leaveCallTriggered = false;
}
}

if (state.status.isDisconnected) {
_logger.d(() => '[leave] rejected (state.status is disconnected)');
return const Result.success(none);
}
/// Shared cleanup sequence for [leave] and [end].
///
/// Sets [_leaveCallTriggered], completes [_callLifecycleCompleter], sends
/// the SFU leave message, and runs [_clear]. Returns `true` when the
/// cleanup actually ran; `false` if it was short-circuited because a
/// concurrent disconnect was already in flight or the call was already
/// disconnected.
Future<bool> _disconnect({required String sfuLeaveReason}) async {
if (_leaveCallTriggered) {
_logger.i(() => '[disconnect] rejected (already disconnecting)');
return false;
}

try {
_session?.leave(reason: _sfuLeaveReason(reason));
} finally {
await _clear('leave');
}
_leaveCallTriggered = true;

_stateManager.lifecycleCallDisconnected(reason: reason);
// Complete the lifecycle completer to cancel ongoing operations awaiting
// it (e.g. _startSession). This must run regardless of whether the
// disconnect proceeds further so that nothing gets stuck waiting.
if (!_callLifecycleCompleter.isCompleted) {
_callLifecycleCompleter.complete();
}

_logger.v(() => '[leave] finished');
if (state.value.status.isDisconnected) {
_logger.d(() => '[disconnect] rejected (status is disconnected)');
return false;
}

return const Result.success(none);
try {
_session?.leave(reason: sfuLeaveReason);
} finally {
_leaveCallTriggered = false;
await _clear('disconnect');
}

return true;
}

String _sfuLeaveReason(DisconnectReason? reason) {
Expand Down Expand Up @@ -2109,6 +2143,7 @@ class Call {
]) {
timer.cancel();
}

_videoModerationTimer?.cancel();
_videoModerationTimer = null;

Expand All @@ -2132,7 +2167,9 @@ class Call {

if (_session != null) {
unawaited(
_session!.dispose().catchError((Object e) {
_session!.dispose().catchError((
Object e,
) {
_logger.w(() => '[clear] session dispose failed: $e');
}),
);
Expand Down Expand Up @@ -2288,7 +2325,9 @@ class Call {
if (CurrentPlatform.isIos) {
await _session?.rtcManager?.setAppleAudioConfiguration(
speakerOn: _connectOptions.speakerDefaultOn,
policy: _streamVideo.options.audioConfigurationPolicy,
policy:
_stateManager.callState.preferences.audioConfigurationPolicy ??
_streamVideo.options.audioConfigurationPolicy,
);
}
}
Expand Down Expand Up @@ -3295,9 +3334,23 @@ class Call {
}
}

/// Enables or disables the microphone for this call.
///
/// When [enabled] is `false`, [disableMode] controls how the local audio
/// track is muted. Defaults to [TrackDisableMode.stopTracks], which
/// releases the microphone hardware on mute so the system privacy
/// indicator turns off. Pass [TrackDisableMode.disableTracks] to keep
/// the capture session alive — this avoids the brief iOS
/// `AVAudioSession` teardown that otherwise ducks playback of other
/// participants for ~1–2 s during mute/unmute, at the cost of the
/// system microphone indicator remaining visible while muted.
/// Recommended for audio rooms and other playback-sensitive use cases.
///
/// See [TrackDisableMode] for the full tradeoff.
Future<Result<None>> setMicrophoneEnabled({
required bool enabled,
AudioConstraints? constraints,
TrackDisableMode? disableMode,
}) async {
if (enabled &&
state.value.isVideoModerated &&
Expand All @@ -3313,6 +3366,7 @@ class Call {
await _session?.setMicrophoneEnabled(
enabled,
constraints: constraints,
disableMode: disableMode,
) ??
Result.error('Session is null');

Expand Down Expand Up @@ -3349,7 +3403,15 @@ class Call {
return result.map((_) => none);
}

Future<bool> requestScreenSharePermission() {
Future<bool> requestScreenSharePermission() async {
// Request screen share permission from the native factory if available
final nativeFactory = await _session?.rtcManager?.pcFactory
.ensureNativeFactory();

if (nativeFactory != null) {
return nativeFactory.requestCapturePermission();
}

return Helper.requestCapturePermission();
}

Expand Down
29 changes: 17 additions & 12 deletions packages/stream_video/lib/src/call/session/call_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class CallSession extends Disposable {
required Tracer tracer,
this.clientPublishOptions,
this.joinResponseTimeout = const Duration(seconds: 5),
AudioConfigurationPolicy? audioConfigurationPolicy,
}) : _tracer = tracer,
_streamVideo = streamVideo,
sfuClient = SfuClient(
Expand All @@ -81,6 +82,7 @@ class CallSession extends Disposable {
callCid: callCid,
configuration: config.rtcConfig,
sdpEditor: sdpEditor,
audioConfigurationPolicy: audioConfigurationPolicy,
) {
_logger.i(() => '<init> callCid: $callCid, sessionId: $sessionId');
_observeNetworkStatus();
Expand Down Expand Up @@ -520,7 +522,9 @@ class CallSession extends Disposable {
StreamWebSocketCloseCode code, {
String? closeReason,
}) async {
_logger.d(() => '[close] code: $code, closeReason: $closeReason');
_logger.d(
() => '[close] code: $code, closeReason: $closeReason',
);
_isLeavingOrClosed = true;

await _eventsSubscription?.cancel();
Expand All @@ -546,7 +550,10 @@ class CallSession extends Disposable {

if (rtcManager != null) {
unawaited(
rtcManager!.dispose().catchError((Object e, StackTrace stk) {
rtcManager!.dispose().catchError((
Object e,
StackTrace stk,
) {
_logger.w(() => '[close] rtcManager.dispose failed: $e');
}),
);
Expand Down Expand Up @@ -1042,22 +1049,20 @@ class CallSession extends Disposable {
Future<Result<RtcLocalTrack>> setMicrophoneEnabled(
bool enabled, {
AudioConstraints? constraints,
TrackDisableMode? disableMode,
}) async {
final rtcManager = this.rtcManager;
if (rtcManager == null) {
return Result.error('Unable to set microphone, Call not connected');
}

final result = TracerZone.run(
_zonedTracer,
++zonedTracerSeq,
() async {
return rtcManager.setMicrophoneEnabled(
enabled: enabled,
constraints: constraints,
);
},
);
final result = TracerZone.run(_zonedTracer, ++zonedTracerSeq, () async {
return rtcManager.setMicrophoneEnabled(
enabled: enabled,
constraints: constraints,
disableMode: disableMode,
);
});

return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class CallSessionFactory {
required StreamVideo streamVideo,
ClientPublishOptions? clientPublishOptions,
List<TraceRecord> leftoverTraceRecords = const [],
AudioConfigurationPolicy? audioConfigurationPolicy,
}) async {
final finalSessionId = sessionId ?? const Uuid().v4();
_logger.d(() => '[makeCallSession] sessionId: $finalSessionId($sessionId)');
Expand Down Expand Up @@ -98,6 +99,7 @@ class CallSessionFactory {
statsOptions: statsOptions,
streamVideo: streamVideo,
tracer: tracer,
audioConfigurationPolicy: audioConfigurationPolicy,
);
}

Expand Down
Loading
Loading